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 avatar = characterDetail?.avatar || '/test.png';
const fromImage = characterDetail?.fromImage || '/images/character/from.png';
const avatar = characterDetail?.characterStand || '/test.png';
const fromImage =
characterDetail?.headPortrait || '/images/character/from.png';
const characterName = characterDetail?.name || '未知角色';
return (
<article className="flex w-full gap-7.5">
{/* 角色头像 - 使用 figure 语义化标签 */}
<figure>
<Image
<img
className="rounded-[30px]"
src={avatar}
alt={`${characterName} - 角色头像`}
width={338}
height={600}
priority
/>
</figure>
@ -93,8 +93,8 @@ export default function CharacterBasicInfo({
<div className="mt-10">
<Tags
options={characterDetail.tags.map((tag: any) => ({
label: tag,
value: tag,
label: tag.name,
value: tag.tagId,
}))}
/>
</div>
@ -130,7 +130,7 @@ export default function CharacterBasicInfo({
{from && (
<div className="bg-text-color/5 flex h-15 items-center justify-between rounded-lg pr-5">
<div className="flex items-center">
<Image
<img
src={fromImage}
alt={`$来源作品封面`}
className="rounded-lg"

View File

@ -1,15 +1,13 @@
'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';
import { useState } from 'react';
export default function TabsNavigation() {
const router = useRouter();
const pathname = usePathname();
const { id } = useParams();
const [activeKey, setActiveKey] = useState<'/review' | '/list'>('/review');
const tabs = [
{ 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">
{tabs.map((tab, index) => {
const isActive = pathname.includes(tab.path);
const isActive = activeKey === tab.path;
const dom = (
<div
onClick={() => router.push(`/character/${id}${tab.path}`)}
onClick={() => setActiveKey(tab.path as any)}
className={cn(
'flex gap-2.5 hover:cursor-pointer',
!isActive && 'text-text-color/60'
@ -49,7 +47,7 @@ export default function TabsNavigation() {
})}
</div>
<div className="mt-10 w-full max-w-300">
{pathname.includes('/review') ? <CharacterReview /> : <CharacterList />}
{activeKey === '/review' ? <CharacterReview /> : <CharacterList />}
</div>
</>
);

View File

@ -4,6 +4,8 @@ import Image from 'next/image';
import TabsNavigation from './components/TabsNavigation';
import { fetchCharacterDetail } from '../../service-server';
export const revalidate = 300;
type CharacterDetailLayoutProps = {
params: Promise<{
id: string;

View File

@ -13,10 +13,8 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
return (
<div
onClick={() => router.push(`/character/${item.id}`)}
className="cover-bg relative flex h-full w-full cursor-pointer flex-col justify-between overflow-hidden rounded-[20]"
style={{
backgroundImage: `url(${item.coverImage || '/test.png'})`,
}}
className="cover-bg relative flex h-full w-full cursor-pointer flex-col justify-between overflow-hidden rounded-[20px]"
style={{ backgroundImage: `url(${item.coverImage})` }}
>
{/* from */}
<img
@ -27,23 +25,47 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
/>
{/* info */}
<div className="px-2.5 pb-3">
<div title={item.name} className="truncate font-bold">
{item.name}
</div>
<Tags
className="mt-2.5"
options={[
{ label: 'tag1', value: 'tag1' },
{ label: 'tag2', value: 'tag2' },
]}
<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="text-text-color/60 mt-4 text-sm">
{item.description}
</div>
<div className="flex justify-between">
<Rate size="small" value={item.rate || 7} readonly />
<div></div>
<div className="relative z-10">
<div title={item.name} className="truncate font-bold">
{item.name}
</div>
<Tags
className="mt-2.5"
options={[
{ label: 'tag1', value: 'tag1' },
{ label: 'tag2', value: 'tag2' },
]}
/>
<div className="text-text-color/60 mt-4 line-clamp-3 text-sm">
{item.description}
</div>
<div className="flex justify-between">
<Rate size="small" value={item.rate || 7} readonly />
<div></div>
</div>
</div>
</div>
</div>
@ -58,9 +80,9 @@ export default function Novel() {
noMoreData,
onLoadMore,
onSearch,
} = useSmartInfiniteQuery(fetchCharacters, {
} = useSmartInfiniteQuery<any, any>(fetchCharacters, {
queryKey: 'characters',
defaultQuery: { tagId: undefined },
defaultQuery: { tagIds: [] },
});
// 使用useQuery查询tags
@ -87,9 +109,10 @@ export default function Novel() {
header={
<TagSelect
options={tags}
mode="multiple"
render={(item) => `# ${item.label}`}
onChange={(v) => {
onSearch({ tagId: v as any });
onSearch({ tagIds: v as string[] });
}}
className="mx-12.5 my-7.5 mb-7.5"
/>

View File

@ -1,12 +1,12 @@
import { cache } from 'react';
import { publicServerRequest } from '@/lib/server/request';
import { serverRequest } from '@/lib/server/request';
export const fetchCharacterDetail = cache(async (id: string) => {
const res = await publicServerRequest(`/character/detail`, {
method: 'post',
params: {
roleId: id,
},
const res = await serverRequest(`/character/detail`, {
method: 'POST',
data: { id },
// Next.js 缓存:每小时重新验证一次
revalidate: 3600,
});
return res.data ?? {};
});

View File

@ -92,7 +92,7 @@ function Select(props: SelectProps) {
<Portal>
<Content
className={cn(
'rounded-[20] border border-white/10',
'rounded-[20px] border border-white/10',
'overflow-hidden',
contentClassName,
'bg-black'

View File

@ -1,4 +1,3 @@
import axios, { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { getServerToken } from './auth';
type ResponseType<T = any> = {
@ -7,76 +6,90 @@ type ResponseType<T = any> = {
data: T;
};
/**
* SSR
* cookies token Authorization header
* tokentoken
*/
async function serverRequest<T = any>(
export async function fetchServerRequest<T = any>(
url: string,
config?: AxiosRequestConfig & {
requireAuth?: boolean; // 是否必须需要 token默认 false可选
options?: {
method?: 'GET' | 'POST';
params?: Record<string, any>;
data?: Record<string, any>;
requireAuth?: boolean;
// Next.js 缓存选项
revalidate?: number | false; // 秒数,或 false 表示永不过期
tags?: string[]; // 缓存标签,用于手动刷新
}
): Promise<ResponseType<T>> {
const { requireAuth = false, ...requestConfig } = config || {};
const {
method = 'GET',
params,
data,
requireAuth = false,
revalidate,
tags,
} = options || {};
// 从 cookies 中获取 token
const token = await getServerToken();
// 如果需要 token 但没有 token可以选择抛出错误或继续请求
// 这里选择继续请求,让后端决定是否允许访问
if (requireAuth && !token) {
throw new Error('Authentication required');
// 获取 token(如果需要)
let token: string | null = null;
if (requireAuth) {
token = await getServerToken();
if (!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;
// 构建请求配置
const fetchOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
},
});
// Next.js 缓存配置
next: {
...(revalidate !== undefined && { revalidate }),
...(tags && { tags }),
},
};
// 设置请求拦截器,添加 token 到 Authorization header
// 与客户端 request.ts 保持一致
// 注意token 是可选的,用于 SEO 的公开数据可以不带 token
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (token) {
config.headers.setAuthorization(`Bearer ${token}`);
}
return config;
});
// 构建完整 URL服务端必须用完整 URL
const baseURL = 'http://54.223.196.180:8091';
const fullURL = new URL(url, baseURL);
// 处理参数
let data: any;
if (requestConfig && requestConfig?.params) {
const { params } = requestConfig;
data = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== '')
// 处理查询参数
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, {
...requestConfig,
params: data,
});
const response = await fetch(fullURL.toString(), fetchOptions);
if (!response.ok) {
return { code: 400, message: '请求失败', data: {} as T };
}
return response.data;
return await response.json();
}
/**
* token
* SEO
*/
export async function publicServerRequest<T = any>(
export async function serverRequest<T = any>(
url: string,
config?: AxiosRequestConfig
options?: {
method?: 'GET' | 'POST';
params?: Record<string, any>;
data?: Record<string, any>;
revalidate?: number | false;
tags?: string[];
}
): Promise<ResponseType<T>> {
return serverRequest<T>(url, { ...config, requireAuth: false });
return fetchServerRequest<T>(url, { ...options, requireAuth: false });
}
export default serverRequest;