feat: 角色列表和详情

This commit is contained in:
liuyonghe0111 2025-11-11 18:24:50 +08:00
parent e2d9348ee0
commit d75223f6cd
7 changed files with 135 additions and 99 deletions

View File

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

View File

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

View File

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

View File

@ -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"
/> />

View File

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

View File

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

View File

@ -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
* tokentoken
*/
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;