feat: 角色列表和详情
This commit is contained in:
parent
e2d9348ee0
commit
d75223f6cd
|
|
@ -22,21 +22,21 @@ export default function CharacterBasicInfo({
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = characterDetail?.from || "The CEO's Contract Wife";
|
const from = characterDetail?.from || "The CEO's Contract Wife";
|
||||||
const avatar = characterDetail?.avatar || '/test.png';
|
const avatar = characterDetail?.characterStand || '/test.png';
|
||||||
const fromImage = characterDetail?.fromImage || '/images/character/from.png';
|
const fromImage =
|
||||||
|
characterDetail?.headPortrait || '/images/character/from.png';
|
||||||
const characterName = characterDetail?.name || '未知角色';
|
const characterName = characterDetail?.name || '未知角色';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="flex w-full gap-7.5">
|
<article className="flex w-full gap-7.5">
|
||||||
{/* 角色头像 - 使用 figure 语义化标签 */}
|
{/* 角色头像 - 使用 figure 语义化标签 */}
|
||||||
<figure>
|
<figure>
|
||||||
<Image
|
<img
|
||||||
className="rounded-[30px]"
|
className="rounded-[30px]"
|
||||||
src={avatar}
|
src={avatar}
|
||||||
alt={`${characterName} - 角色头像`}
|
alt={`${characterName} - 角色头像`}
|
||||||
width={338}
|
width={338}
|
||||||
height={600}
|
height={600}
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
|
@ -93,8 +93,8 @@ export default function CharacterBasicInfo({
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<Tags
|
<Tags
|
||||||
options={characterDetail.tags.map((tag: any) => ({
|
options={characterDetail.tags.map((tag: any) => ({
|
||||||
label: tag,
|
label: tag.name,
|
||||||
value: tag,
|
value: tag.tagId,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,7 +130,7 @@ export default function CharacterBasicInfo({
|
||||||
{from && (
|
{from && (
|
||||||
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
|
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Image
|
<img
|
||||||
src={fromImage}
|
src={fromImage}
|
||||||
alt={`$来源作品封面`}
|
alt={`$来源作品封面`}
|
||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
|
||||||
import { cn } from '@/lib';
|
import { cn } from '@/lib';
|
||||||
import { CommentIcon } from '@/assets/chatacter';
|
import { CommentIcon } from '@/assets/chatacter';
|
||||||
import CharacterReview from './Review';
|
import CharacterReview from './Review';
|
||||||
import CharacterList from './List';
|
import CharacterList from './List';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
export default function TabsNavigation() {
|
export default function TabsNavigation() {
|
||||||
const router = useRouter();
|
const [activeKey, setActiveKey] = useState<'/review' | '/list'>('/review');
|
||||||
const pathname = usePathname();
|
|
||||||
const { id } = useParams();
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ path: '/review', Icon: CommentIcon, label: 'Review' },
|
{ path: '/review', Icon: CommentIcon, label: 'Review' },
|
||||||
|
|
@ -23,10 +21,10 @@ export default function TabsNavigation() {
|
||||||
<>
|
<>
|
||||||
<div className="mt-11 flex w-full max-w-300 items-center justify-start gap-5">
|
<div className="mt-11 flex w-full max-w-300 items-center justify-start gap-5">
|
||||||
{tabs.map((tab, index) => {
|
{tabs.map((tab, index) => {
|
||||||
const isActive = pathname.includes(tab.path);
|
const isActive = activeKey === tab.path;
|
||||||
const dom = (
|
const dom = (
|
||||||
<div
|
<div
|
||||||
onClick={() => router.push(`/character/${id}${tab.path}`)}
|
onClick={() => setActiveKey(tab.path as any)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-2.5 hover:cursor-pointer',
|
'flex gap-2.5 hover:cursor-pointer',
|
||||||
!isActive && 'text-text-color/60'
|
!isActive && 'text-text-color/60'
|
||||||
|
|
@ -49,7 +47,7 @@ export default function TabsNavigation() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 w-full max-w-300">
|
<div className="mt-10 w-full max-w-300">
|
||||||
{pathname.includes('/review') ? <CharacterReview /> : <CharacterList />}
|
{activeKey === '/review' ? <CharacterReview /> : <CharacterList />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import Image from 'next/image';
|
||||||
import TabsNavigation from './components/TabsNavigation';
|
import TabsNavigation from './components/TabsNavigation';
|
||||||
import { fetchCharacterDetail } from '../../service-server';
|
import { fetchCharacterDetail } from '../../service-server';
|
||||||
|
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
type CharacterDetailLayoutProps = {
|
type CharacterDetailLayoutProps = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,8 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => router.push(`/character/${item.id}`)}
|
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-[20px]"
|
||||||
style={{
|
style={{ backgroundImage: `url(${item.coverImage})` }}
|
||||||
backgroundImage: `url(${item.coverImage || '/test.png'})`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* from */}
|
{/* from */}
|
||||||
<img
|
<img
|
||||||
|
|
@ -27,7 +25,30 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* info */}
|
{/* info */}
|
||||||
<div className="px-2.5 pb-3">
|
<div
|
||||||
|
className="relative overflow-hidden px-2.5 pt-15 pb-3"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.8) 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 模糊背景层 - 从上到下渐变显示 */}
|
||||||
|
<div
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px)',
|
||||||
|
maskImage: 'linear-gradient(to bottom, transparent 0%, black 100%)',
|
||||||
|
WebkitMaskImage:
|
||||||
|
'linear-gradient(to bottom, transparent 0%, black 100%)',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
top: '0',
|
||||||
|
bottom: '0',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
<div title={item.name} className="truncate font-bold">
|
<div title={item.name} className="truncate font-bold">
|
||||||
{item.name}
|
{item.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -38,7 +59,7 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
||||||
{ label: 'tag2', value: 'tag2' },
|
{ label: 'tag2', value: 'tag2' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className="text-text-color/60 mt-4 text-sm">
|
<div className="text-text-color/60 mt-4 line-clamp-3 text-sm">
|
||||||
{item.description}
|
{item.description}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|
@ -47,6 +68,7 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,9 +80,9 @@ export default function Novel() {
|
||||||
noMoreData,
|
noMoreData,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onSearch,
|
onSearch,
|
||||||
} = useSmartInfiniteQuery(fetchCharacters, {
|
} = useSmartInfiniteQuery<any, any>(fetchCharacters, {
|
||||||
queryKey: 'characters',
|
queryKey: 'characters',
|
||||||
defaultQuery: { tagId: undefined },
|
defaultQuery: { tagIds: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用useQuery查询tags
|
// 使用useQuery查询tags
|
||||||
|
|
@ -87,9 +109,10 @@ export default function Novel() {
|
||||||
header={
|
header={
|
||||||
<TagSelect
|
<TagSelect
|
||||||
options={tags}
|
options={tags}
|
||||||
|
mode="multiple"
|
||||||
render={(item) => `# ${item.label}`}
|
render={(item) => `# ${item.label}`}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
onSearch({ tagId: v as any });
|
onSearch({ tagIds: v as string[] });
|
||||||
}}
|
}}
|
||||||
className="mx-12.5 my-7.5 mb-7.5"
|
className="mx-12.5 my-7.5 mb-7.5"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { cache } from 'react';
|
import { cache } from 'react';
|
||||||
import { publicServerRequest } from '@/lib/server/request';
|
import { serverRequest } from '@/lib/server/request';
|
||||||
|
|
||||||
export const fetchCharacterDetail = cache(async (id: string) => {
|
export const fetchCharacterDetail = cache(async (id: string) => {
|
||||||
const res = await publicServerRequest(`/character/detail`, {
|
const res = await serverRequest(`/character/detail`, {
|
||||||
method: 'post',
|
method: 'POST',
|
||||||
params: {
|
data: { id },
|
||||||
roleId: id,
|
// Next.js 缓存:每小时重新验证一次
|
||||||
},
|
revalidate: 3600,
|
||||||
});
|
});
|
||||||
return res.data ?? {};
|
return res.data ?? {};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ function Select(props: SelectProps) {
|
||||||
<Portal>
|
<Portal>
|
||||||
<Content
|
<Content
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-[20] border border-white/10',
|
'rounded-[20px] border border-white/10',
|
||||||
'overflow-hidden',
|
'overflow-hidden',
|
||||||
contentClassName,
|
contentClassName,
|
||||||
'bg-black'
|
'bg-black'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
|
||||||
import { getServerToken } from './auth';
|
import { getServerToken } from './auth';
|
||||||
|
|
||||||
type ResponseType<T = any> = {
|
type ResponseType<T = any> = {
|
||||||
|
|
@ -7,76 +6,90 @@ type ResponseType<T = any> = {
|
||||||
data: T;
|
data: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export async function fetchServerRequest<T = any>(
|
||||||
* 服务端请求函数,用于 SSR
|
|
||||||
* 从 cookies 中获取 token 并添加到 Authorization header
|
|
||||||
* 如果接口不需要 token(如公开数据),token 会自动省略
|
|
||||||
*/
|
|
||||||
async function serverRequest<T = any>(
|
|
||||||
url: string,
|
url: string,
|
||||||
config?: AxiosRequestConfig & {
|
options?: {
|
||||||
requireAuth?: boolean; // 是否必须需要 token,默认 false(可选)
|
method?: 'GET' | 'POST';
|
||||||
|
params?: Record<string, any>;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
requireAuth?: boolean;
|
||||||
|
// Next.js 缓存选项
|
||||||
|
revalidate?: number | false; // 秒数,或 false 表示永不过期
|
||||||
|
tags?: string[]; // 缓存标签,用于手动刷新
|
||||||
}
|
}
|
||||||
): Promise<ResponseType<T>> {
|
): Promise<ResponseType<T>> {
|
||||||
const { requireAuth = false, ...requestConfig } = config || {};
|
const {
|
||||||
|
method = 'GET',
|
||||||
|
params,
|
||||||
|
data,
|
||||||
|
requireAuth = false,
|
||||||
|
revalidate,
|
||||||
|
tags,
|
||||||
|
} = options || {};
|
||||||
|
|
||||||
// 从 cookies 中获取 token
|
// 获取 token(如果需要)
|
||||||
const token = await getServerToken();
|
let token: string | null = null;
|
||||||
|
if (requireAuth) {
|
||||||
// 如果需要 token 但没有 token,可以选择抛出错误或继续请求
|
token = await getServerToken();
|
||||||
// 这里选择继续请求,让后端决定是否允许访问
|
if (!token) {
|
||||||
if (requireAuth && !token) {
|
|
||||||
throw new Error('Authentication required');
|
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;
|
const fetchOptions: RequestInit = {
|
||||||
if (requestConfig && requestConfig?.params) {
|
method,
|
||||||
const { params } = requestConfig;
|
headers: {
|
||||||
data = Object.fromEntries(
|
'Content-Type': 'application/json',
|
||||||
Object.entries(params).filter(([, value]) => value !== '')
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
},
|
||||||
|
// Next.js 缓存配置
|
||||||
|
next: {
|
||||||
|
...(revalidate !== undefined && { revalidate }),
|
||||||
|
...(tags && { tags }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建完整 URL(服务端必须用完整 URL)
|
||||||
|
const baseURL = 'http://54.223.196.180:8091';
|
||||||
|
const fullURL = new URL(url, baseURL);
|
||||||
|
|
||||||
|
// 处理查询参数
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
if (value !== '') {
|
||||||
|
fullURL.searchParams.append(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 body 数据
|
||||||
|
if (data) {
|
||||||
|
fetchOptions.body = JSON.stringify(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([, value]) => value !== '')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('url', url);
|
|
||||||
|
|
||||||
// 发起请求
|
// 发起请求
|
||||||
const response = await instance<ResponseType<T>>(url, {
|
const response = await fetch(fullURL.toString(), fetchOptions);
|
||||||
...requestConfig,
|
if (!response.ok) {
|
||||||
params: data,
|
return { code: 400, message: '请求失败', data: {} as T };
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return await response.json();
|
||||||
* 公开数据请求函数(不需要 token)
|
}
|
||||||
* 用于 SEO 友好的公开数据接口
|
|
||||||
*/
|
export async function serverRequest<T = any>(
|
||||||
export async function publicServerRequest<T = any>(
|
|
||||||
url: string,
|
url: string,
|
||||||
config?: AxiosRequestConfig
|
options?: {
|
||||||
): Promise<ResponseType<T>> {
|
method?: 'GET' | 'POST';
|
||||||
return serverRequest<T>(url, { ...config, requireAuth: false });
|
params?: Record<string, any>;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
revalidate?: number | false;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
): Promise<ResponseType<T>> {
|
||||||
|
return fetchServerRequest<T>(url, { ...options, requireAuth: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default serverRequest;
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue