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 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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,7 +25,30 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
|||
/>
|
||||
|
||||
{/* 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">
|
||||
{item.name}
|
||||
</div>
|
||||
|
|
@ -38,7 +59,7 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
|||
{ 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}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
|
@ -47,6 +68,7 @@ const RoleCard: React.FC<any> = React.memo(({ item }) => {
|
|||
</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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 ?? {};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 如果接口不需要 token(如公开数据),token 会自动省略
|
||||
*/
|
||||
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) {
|
||||
// 获取 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;
|
||||
},
|
||||
});
|
||||
|
||||
// 设置请求拦截器,添加 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 !== '')
|
||||
// 构建请求配置
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(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, {
|
||||
...requestConfig,
|
||||
params: data,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
const response = await fetch(fullURL.toString(), fetchOptions);
|
||||
if (!response.ok) {
|
||||
return { code: 400, message: '请求失败', data: {} as T };
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开数据请求函数(不需要 token)
|
||||
* 用于 SEO 友好的公开数据接口
|
||||
*/
|
||||
export async function publicServerRequest<T = any>(
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function serverRequest<T = any>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ResponseType<T>> {
|
||||
return serverRequest<T>(url, { ...config, requireAuth: false });
|
||||
options?: {
|
||||
method?: 'GET' | 'POST';
|
||||
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