feat: 增加角色详情SSR
This commit is contained in:
parent
0cb63f144f
commit
ef0bdff317
|
|
@ -12,9 +12,6 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@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",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"ahooks": "^3.9.5",
|
"ahooks": "^3.9.5",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
|
|
@ -26,6 +23,7 @@
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
"next-intl": "^4.3.11",
|
"next-intl": "^4.3.11",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
|
|
|
||||||
978
pnpm-lock.yaml
978
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 586 KiB |
|
|
@ -1,121 +1,153 @@
|
||||||
'use client';
|
import { Rate } from '@/components';
|
||||||
import { TagSelect, Rate } from '@/components';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { RightArrowIcon } from '@/assets/chatacter';
|
import { RightArrowIcon } from '@/assets/chatacter';
|
||||||
import { ReportIcon } from '@/assets/common';
|
import IconFont from '@/components/ui/iconFont';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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 (
|
return (
|
||||||
<div className="flex w-full">
|
<article className="flex w-full gap-7.5">
|
||||||
<Image
|
{/* 角色头像 - 使用 figure 语义化标签 */}
|
||||||
src="/test.png"
|
<figure>
|
||||||
alt="character-basic-info"
|
<Image
|
||||||
width={338}
|
src={avatar}
|
||||||
height={600}
|
alt={`${characterName} - 角色头像`}
|
||||||
/>
|
width={338}
|
||||||
<div className="w-full pt-5">
|
height={600}
|
||||||
<div className="flex items-center justify-between">
|
priority
|
||||||
<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',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
{/* divider */}
|
</figure>
|
||||||
<div className="bg-text-color/10 mt-10 h-0.25 w-full"></div>
|
|
||||||
|
|
||||||
{/* description */}
|
<div className="w-full pt-5">
|
||||||
<div className="mt-10.5">
|
{/* 角色名称 - 使用 header 和 h1 */}
|
||||||
<div className="flex">
|
<header className="flex items-center justify-between">
|
||||||
<Image
|
<h1 className="text-text-color text-4xl font-bold">
|
||||||
src={'/character/desc.svg'}
|
{characterName}
|
||||||
alt="description"
|
</h1>
|
||||||
className="mr-1"
|
<div>
|
||||||
width={18}
|
<IconFont type="icon-jubao" size={20} />
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Description:
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-text-color/60 mt-5 text-sm">
|
</header>
|
||||||
{
|
|
||||||
'description text description textdescription textdescription textdescription textdescription textdescription textdescription text'
|
{/* 角色基本信息 - 使用定义列表 */}
|
||||||
}
|
<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>
|
||||||
|
<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>
|
</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 */}
|
{/* from and chat */}
|
||||||
<div className="flex items-center justify-between gap-10 pt-5">
|
<div className="flex items-center justify-between gap-10 pt-5">
|
||||||
{/* FROM */}
|
{/* FROM - 使用语义化标签 */}
|
||||||
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
|
{from && (
|
||||||
<div className="flex items-center">
|
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
|
||||||
<Image
|
<div className="flex items-center">
|
||||||
src="/images/character/from.png"
|
<Image
|
||||||
alt="from"
|
src={fromImage}
|
||||||
className="rounded-lg"
|
alt={`${from} - 来源作品封面`}
|
||||||
width={45}
|
className="rounded-lg"
|
||||||
height={60}
|
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>
|
<div className="text-text-color/80 max-w-110 pr-2 text-sm">
|
||||||
{" The CEO's Contract Wife"}
|
<span style={{ color: 'rgb(0, 102, 255)' }}>Form:</span>
|
||||||
|
{` ${from}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RightArrowIcon />
|
||||||
</div>
|
</div>
|
||||||
<RightArrowIcon />
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chat Button */}
|
{/* Chat Button - 客户端组件 */}
|
||||||
<div
|
<ChatButton characterId={characterId} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HeartIcon, ReplyIcon } from '@/assets/chatacter';
|
import { HeartIcon, ReplyIcon } from '@/assets/chatacter';
|
||||||
import { ReportIcon } from '@/assets/common';
|
|
||||||
import { Rate } from '@/components';
|
import { Rate } from '@/components';
|
||||||
|
import IconFont from '@/components/ui/iconFont';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
const Comment = () => {
|
const Comment = () => {
|
||||||
|
|
@ -45,7 +45,7 @@ const Comment = () => {
|
||||||
<span>999</span>
|
<span>999</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ReportIcon />
|
<IconFont type="icon-jubao" size={20} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,27 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
import IconFont from '@/components/ui/iconFont';
|
||||||
|
|
||||||
export default function ArchiveHistory() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,49 @@
|
||||||
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 IconFont from '@/components/ui/iconFont';
|
||||||
|
|
||||||
const ChatHistory = React.memo(() => {
|
const ChatHistory = React.memo(() => {
|
||||||
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
|
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center justify-between">
|
||||||
<span>Chat</span>
|
<span className="text-lg font-black">Chat</span>
|
||||||
<div onClick={() => setHistoryListOpen(false)}>x</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,17 @@ 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 { BackIcon, CharaterHistoryIcon } from '@/assets/chatacter';
|
import { CharaterHistoryIcon } from '@/assets/chatacter';
|
||||||
import { useRouter } from 'next/navigation';
|
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() {
|
export default function Side() {
|
||||||
const [activeKey, setActiveKey] = useAtom(leftTabActiveKeyAtom);
|
const [activeKey, setActiveKey] = useAtom(leftTabActiveKeyAtom);
|
||||||
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
|
const setHistoryListOpen = useSetAtom(historyListOpenAtom);
|
||||||
|
const Component = activeKey === 'info' ? Info : ArchiveHistory;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
|
@ -56,51 +60,62 @@ export default function Side() {
|
||||||
element: (
|
element: (
|
||||||
<div
|
<div
|
||||||
onClick={() => router.back()}
|
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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-25 flex-col justify-between bg-[#15121A]">
|
<>
|
||||||
<div className="flex flex-col gap-9 pt-7.5">
|
{/* 左侧路由Bar */}
|
||||||
{tabs.map((item) => {
|
<div className="flex h-full w-25 flex-col justify-between bg-black/20">
|
||||||
const isActive = activeKey === item.key;
|
<div className="flex flex-col gap-9 pt-7.5">
|
||||||
return (
|
{tabs.map((item) => {
|
||||||
<div
|
const isActive = activeKey === item.key;
|
||||||
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) {
|
|
||||||
return (
|
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}
|
{item.element}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
// divider
|
</div>
|
||||||
return (
|
<div className="mb-10 flex flex-col gap-7.5">
|
||||||
<div
|
{bottomActions.map((item, index) => {
|
||||||
className="ml-12 h-0.5 w-5 bg-[rgba(44,42,49,1)]"
|
if (item.element) {
|
||||||
key={`divider_${index}`}
|
return (
|
||||||
></div>
|
<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>
|
||||||
</div>
|
|
||||||
|
{/* 路由content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Component />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,20 @@
|
||||||
|
|
||||||
import Side from './Side';
|
import Side from './Side';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { historyListOpenAtom, leftTabActiveKeyAtom } from '../atoms';
|
import { historyListOpenAtom } from '../atoms';
|
||||||
import Info from './info';
|
import { memo } from 'react';
|
||||||
import ArchiveHistory from './ArchiveHistory';
|
|
||||||
import { memo, useRef } from 'react';
|
|
||||||
import { Drawer } from '@/components';
|
import { Drawer } from '@/components';
|
||||||
import ChatHistory from './ChatHisory';
|
import ChatHistory from './ChatHisory';
|
||||||
|
|
||||||
const Left = memo(() => {
|
const Left = memo(() => {
|
||||||
const activeKey = useAtomValue(leftTabActiveKeyAtom);
|
|
||||||
const Component = activeKey === 'info' ? Info : ArchiveHistory;
|
|
||||||
const historyListOpen = useAtomValue(historyListOpenAtom);
|
const historyListOpen = useAtomValue(historyListOpenAtom);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative flex h-full w-112">
|
<div className="relative flex h-full w-112">
|
||||||
|
{/* 左侧路由Bar */}
|
||||||
<Side />
|
<Side />
|
||||||
<div className="flex-1">
|
{/* 聊天历史 */}
|
||||||
<Component />
|
<Drawer position="left" width={448} open={historyListOpen} destroyOnClose>
|
||||||
</div>
|
|
||||||
<Drawer
|
|
||||||
getContainer={() => containerRef.current}
|
|
||||||
position="left"
|
|
||||||
width={448}
|
|
||||||
open={historyListOpen}
|
|
||||||
destroyOnClose
|
|
||||||
>
|
|
||||||
<ChatHistory />
|
<ChatHistory />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,89 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
import IconFont from '@/components/ui/iconFont';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Tags from '@/components/ui/Tags';
|
||||||
|
|
||||||
export default function Info() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import { useControllableValue } from 'ahooks';
|
import { useControllableValue } from 'ahooks';
|
||||||
import { cn } from '@/lib';
|
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 = {
|
type BackgroundItem = {
|
||||||
item?: any;
|
item?: any;
|
||||||
|
|
@ -40,7 +41,7 @@ const ItemRender = (props: BackgroundItem) => {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span onClick={handleDelete} className="hover:text-[#E2503D]">
|
<span onClick={handleDelete} className="hover:text-[#E2503D]">
|
||||||
<DeleteIcon size={20} />
|
<IconFont type="icon-delete" size={20} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import React from 'react';
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
import { Select, Switch, Number, FontSize } from '@/components/ui/inputs';
|
import { Select, Switch, Number, FontSize } from '@/components/ui/inputs';
|
||||||
import Background from './Background';
|
import Background from './Background';
|
||||||
import { AddIcon, DeleteIcon } from '@/assets/common';
|
import { AddIcon } from '@/assets/common';
|
||||||
import { ModelSelectDialog } from '@/components';
|
import { ModelSelectDialog } from '@/components';
|
||||||
|
import IconFont from '@/components/ui/iconFont';
|
||||||
|
|
||||||
const Title: React.FC<
|
const Title: React.FC<
|
||||||
{
|
{
|
||||||
|
|
@ -150,9 +151,8 @@ const SettingForm = React.memo(() => {
|
||||||
</Form>
|
</Form>
|
||||||
<div className="mt-12.5 mb-10 flex flex-col gap-5">
|
<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)]">
|
<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>
|
||||||
|
|
||||||
<div className="tk-button border-text-color/80 border-1 hover:bg-white hover:text-[rgba(0,102,255,1)]">
|
<div className="tk-button border-text-color/80 border-1 hover:bg-white hover:text-[rgba(0,102,255,1)]">
|
||||||
<AddIcon /> START NEW CHAT
|
<AddIcon /> START NEW CHAT
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,49 +8,40 @@ import Main from './Main';
|
||||||
import Left from './Left';
|
import Left from './Left';
|
||||||
import { Drawer } from '@/components';
|
import { Drawer } from '@/components';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { useRef } from 'react';
|
|
||||||
import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
|
import { ExitFullScreenIcon, FullScreenIcon } from '@/assets/common';
|
||||||
|
|
||||||
export default function CharacterChat() {
|
export default function CharacterChat() {
|
||||||
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
|
const [settingOpen, setSettingOpen] = useAtom(settingOpenAtom);
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
className="relative flex h-full w-full justify-center overflow-hidden"
|
||||||
>
|
>
|
||||||
<Main />
|
<Main />
|
||||||
|
|
||||||
|
{/* 左侧 */}
|
||||||
|
<Drawer open={settingOpen} position="left" width={448} destroyOnClose>
|
||||||
|
<Left />
|
||||||
|
</Drawer>
|
||||||
|
{/* 右侧设置 */}
|
||||||
|
<Drawer open={settingOpen} position="right" width={448} destroyOnClose>
|
||||||
|
<SettingForm />
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
{/* 设置按钮 */}
|
{/* 设置按钮 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
'text-text-color/10 hover:text-text-color/20'
|
||||||
)}
|
)}
|
||||||
onClick={() => setSettingOpen(!settingOpen)}
|
onClick={() => setSettingOpen(!settingOpen)}
|
||||||
>
|
>
|
||||||
{settingOpen ? <FullScreenIcon /> : <ExitFullScreenIcon />}
|
{settingOpen ? <FullScreenIcon /> : <ExitFullScreenIcon />}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div
|
<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]"
|
className="cover-bg relative flex h-full w-full cursor-pointer flex-col justify-between overflow-hidden rounded-[20]"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${item.from || '/test.png'})`,
|
backgroundImage: `url(${item.from || '/test.png'})`,
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,6 @@
|
||||||
--background: #14132d;
|
--background: #14132d;
|
||||||
--text-color: #fff;
|
--text-color: #fff;
|
||||||
--text-color-1: rgba(174, 196, 223, 1);
|
--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 {
|
@theme inline {
|
||||||
|
|
@ -38,6 +30,26 @@ body {
|
||||||
background-position: center;
|
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 {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import GlobalContainer from '@/layouts/GlobalContainer';
|
import GlobalContainer from '@/layouts/GlobalContainer';
|
||||||
|
import Script from 'next/script';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: '--font-geist-sans',
|
variable: '--font-geist-sans',
|
||||||
|
|
@ -28,6 +29,10 @@ export default function RootLayout({
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<Script
|
||||||
|
src="//at.alicdn.com/t/c/font_5054282_ibxmours7r.js"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
<GlobalContainer>{children}</GlobalContainer>
|
<GlobalContainer>{children}</GlobalContainer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export default function Loading() {
|
export default async function Loading() {
|
||||||
return <p>Loading...</p>;
|
return <div className="flex-center h-screen w-full">Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -259,25 +259,3 @@ export const CharaterHistoryIcon = () => {
|
||||||
</svg>
|
</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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,144 +1,5 @@
|
||||||
import type { IconProps } from '@/types/common';
|
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) => {
|
export const AddIcon = (props: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
type DrawerProps = {
|
type DrawerProps = {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
getContainer?: () => HTMLElement | null;
|
inBody?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
position?: 'left' | 'right' | 'top' | 'bottom';
|
position?: 'left' | 'right' | 'top' | 'bottom';
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|
@ -15,14 +15,15 @@ type DrawerProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 一个抽屉组件,支持打开关闭动画和自定义位置、宽度、z-index **无样式**
|
// 一个抽屉组件,支持打开关闭动画和自定义位置、宽度、z-index **无样式**
|
||||||
|
// 注意inBody为false时,需要设置父组件 position: relative;
|
||||||
export default function Drawer({
|
export default function Drawer({
|
||||||
open = false,
|
open = false,
|
||||||
getContainer,
|
inBody = false,
|
||||||
children,
|
children,
|
||||||
position = 'right',
|
position = 'right',
|
||||||
width = 400,
|
width = 400,
|
||||||
destroyOnClose = false,
|
destroyOnClose = false,
|
||||||
zIndex = 'var(--z-drawer)',
|
zIndex,
|
||||||
}: DrawerProps) {
|
}: DrawerProps) {
|
||||||
// shouldRender 控制是否渲染 DOM(用于 destroyOnClose)
|
// shouldRender 控制是否渲染 DOM(用于 destroyOnClose)
|
||||||
const [shouldRender, setShouldRender] = useState(false);
|
const [shouldRender, setShouldRender] = useState(false);
|
||||||
|
|
@ -108,8 +109,7 @@ export default function Drawer({
|
||||||
return previousState.current.styles;
|
return previousState.current.styles;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有 container,使用 absolute 定位,否则使用 fixed 定位
|
const positionType = inBody ? 'fixed' : 'absolute';
|
||||||
const positionType = getContainer ? 'absolute' : 'fixed';
|
|
||||||
const baseStyles: React.CSSProperties = {
|
const baseStyles: React.CSSProperties = {
|
||||||
...positionStyles,
|
...positionStyles,
|
||||||
position: positionType,
|
position: positionType,
|
||||||
|
|
@ -139,13 +139,23 @@ export default function Drawer({
|
||||||
return baseStyles;
|
return baseStyles;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted || !shouldRender) return null;
|
||||||
|
|
||||||
// 使用 portal 渲染到指定容器,默认是 body
|
if (inBody) {
|
||||||
const targetContainer = getContainer?.() || (mounted ? document.body : null);
|
return createPortal(
|
||||||
if (!targetContainer || !shouldRender) return null;
|
<div
|
||||||
|
ref={drawerRef}
|
||||||
|
style={getPositionStyles()}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
<div
|
||||||
ref={drawerRef}
|
ref={drawerRef}
|
||||||
style={getPositionStyles()}
|
style={getPositionStyles()}
|
||||||
|
|
@ -153,7 +163,6 @@ export default function Drawer({
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>,
|
</div>
|
||||||
targetContainer
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
FormProvider,
|
FormProvider,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -4,7 +4,7 @@ import { cn } from '@/lib';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import rightIcon from '@/assets/components/go_right.svg';
|
import rightIcon from '@/assets/components/go_right.svg';
|
||||||
import Image from 'next/image';
|
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 { useControllableValue } from 'ahooks';
|
||||||
import { InputLeft } from '.';
|
import { InputLeft } from '.';
|
||||||
|
|
||||||
|
|
@ -94,15 +94,15 @@ function Select(props: SelectProps) {
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-[20] border border-white/10',
|
'rounded-[20] border border-white/10',
|
||||||
'overflow-hidden',
|
'overflow-hidden',
|
||||||
contentClassName
|
contentClassName,
|
||||||
|
'bg-black'
|
||||||
)}
|
)}
|
||||||
position="popper"
|
position="popper"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
style={{
|
style={{
|
||||||
width: 'var(--radix-select-trigger-width)', // 默认跟随 Trigger 宽度
|
width: 'var(--radix-select-trigger-width)', // 默认跟随 Trigger 宽度
|
||||||
backgroundColor: 'rgba(26, 23, 34, 1)',
|
zIndex: zIndex || 'var(--z-dropdown)',
|
||||||
zIndex: zIndex || 'var(--z-select)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Viewport className="p-2">
|
<Viewport className="p-2">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
import { useControllableValue } from 'ahooks';
|
import { useControllableValue } from 'ahooks';
|
||||||
import { InputLeft } from '.';
|
import { InputLeft } from '.';
|
||||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
import { Switch as SwitchPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
const { Root, Thumb } = SwitchPrimitive;
|
const { Root, Thumb } = SwitchPrimitive;
|
||||||
type SwitchProps = {
|
type SwitchProps = {
|
||||||
value?: boolean;
|
value?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
.dialog-overlay {
|
.dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: var(--z-modal);
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11,7 +10,6 @@
|
||||||
left: 50%;
|
left: 50%;
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
z-index: var(--z-modal);
|
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useControllableValue } from 'ahooks';
|
import { useControllableValue } from 'ahooks';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,8 @@ import { useMsg } from '@/hooks';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
import {
|
import IconFont from '@/components/ui/iconFont';
|
||||||
NovelIcon,
|
import { Select } from '@/components/ui/inputs';
|
||||||
VideoIcon,
|
|
||||||
CharacterIcon,
|
|
||||||
RecordIcon,
|
|
||||||
} from '@/assets/common';
|
|
||||||
|
|
||||||
const NavRoutes = React.memo(() => {
|
const NavRoutes = React.memo(() => {
|
||||||
const { pageMsg } = useMsg('menu');
|
const { pageMsg } = useMsg('menu');
|
||||||
|
|
@ -19,22 +15,22 @@ const NavRoutes = React.memo(() => {
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/novel',
|
path: '/novel',
|
||||||
icon: NovelIcon,
|
icon: 'icon-novel',
|
||||||
label: pageMsg('novel'),
|
label: pageMsg('novel'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/video',
|
path: '/video',
|
||||||
icon: VideoIcon,
|
icon: 'icon-video',
|
||||||
label: pageMsg('video'),
|
label: pageMsg('video'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/character',
|
path: '/character',
|
||||||
icon: CharacterIcon,
|
icon: 'icon-character',
|
||||||
label: pageMsg('character'),
|
label: pageMsg('character'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/record',
|
path: '/record',
|
||||||
icon: RecordIcon,
|
icon: 'icon-record',
|
||||||
label: pageMsg('record'),
|
label: pageMsg('record'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -46,7 +42,7 @@ const NavRoutes = React.memo(() => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-2',
|
'flex items-center gap-2',
|
||||||
isActive
|
isActive
|
||||||
? 'text-text-color'
|
? 'text-text-color'
|
||||||
: 'text-[#2C223F] hover:text-[#4F3F6D]'
|
: 'text-[#2C223F] hover:text-[#4F3F6D]'
|
||||||
|
|
@ -54,7 +50,7 @@ const NavRoutes = React.memo(() => {
|
||||||
key={route.path}
|
key={route.path}
|
||||||
href={route.path}
|
href={route.path}
|
||||||
>
|
>
|
||||||
<route.icon />
|
<IconFont size={34} type={route.icon} />
|
||||||
{route.label}
|
{route.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
* 如果接口不需要 token(如公开数据),token 会自动省略
|
||||||
|
*/
|
||||||
|
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;
|
||||||
Loading…
Reference in New Issue