feat: 角色列表页面

This commit is contained in:
liuyonghe0111 2025-10-31 10:59:49 +08:00
parent a279653321
commit 0cb63f144f
21 changed files with 430 additions and 1437 deletions

View File

@ -1,46 +1,18 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const endpoints = {
frog: process.env.NEXT_PUBLIC_FROG_API_URL,
bear: process.env.NEXT_PUBLIC_BEAR_API_URL,
lion: process.env.NEXT_PUBLIC_LION_API_URL,
shark: process.env.NEXT_PUBLIC_SHARK_API_URL,
cow: process.env.NEXT_PUBLIC_COW_API_URL,
pigeon: process.env.NEXT_PUBLIC_PIGEON_API_URL,
};
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
// async rewrites() { async rewrites() {
// return [ return [
// { {
// source: '/api/frog/:path*', source: '/api/:path*', // 前端请求 /api/xxx
// destination: `${endpoints.frog}/api/frog/:path*`, destination: 'http://54.223.196.180:8091/:path*', // 实际请求到后端服务器
// }, },
// { ];
// source: '/api/bear/:path*', },
// destination: `${endpoints.bear}/api/bear/:path*`,
// },
// {
// source: '/api/lion/:path*',
// destination: `${endpoints.lion}/api/lion/:path*`,
// },
// {
// source: '/api/shark/:path*',
// destination: `${endpoints.shark}/api/shark/:path*`,
// },
// {
// source: '/api/cow/:path*',
// destination: `${endpoints.cow}/api/cow/:path*`,
// },
// {
// source: '/api/pigeon/:path*',
// destination: `${endpoints.pigeon}/api/pigeon/:path*`,
// },
// ];
// },
}; };
export default nextConfig; export default nextConfig;

View File

@ -12,6 +12,9 @@
}, },
"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",
@ -23,7 +26,6 @@
"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",

File diff suppressed because it is too large Load Diff

View File

@ -1,118 +1,102 @@
'use client'; 'use client';
import { TagSelect, VirtualGrid, Rate } from '@/components'; import { TagSelect, VirtualGrid, Rate } from '@/components';
import { useInfiniteQuery } from '@tanstack/react-query'; import React from 'react';
import React, { useMemo, useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { fetchCharacters } from './service';
import { fetchTags } from '@/services/tag';
import { useQuery } from '@tanstack/react-query';
import Tags from '@/components/ui/Tags';
const request = async (params: any) => { const RoleCard: React.FC<any> = React.memo(({ item }) => {
const pageSize = 20;
await new Promise((resolve) => setTimeout(resolve, 500));
return {
rows: new Array(pageSize).fill(0).map((_, i) => ({
id: `item_${params.index + i}`,
name: `一个提示词${params.index + i}`,
})),
nextPage: params.index + pageSize,
hasMore: params.index + pageSize < 80,
};
};
const RoleCard: React.FC<any> = ({ item }) => {
const router = useRouter(); const router = useRouter();
return ( return (
<div <div
onClick={() => router.push(`/character/${item.id}/review`)} onClick={() => router.push(`/character/${item.id}/chat`)}
className="relative flex h-full w-full flex-col justify-between rounded-[20px] hover:cursor-pointer" 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'})`,
}} }}
> >
{/* from */} {/* from */}
<div> <Image src={item.from || '/test.png'} alt="from" width={55} height={78} />
<Image
src={item.from || '/test.png'}
alt="from"
width={55}
height={78}
/>
</div>
{/* info */} {/* info */}
<div className="px-2.5 pb-3"> <div className="px-2.5 pb-3">
<div className="font-bold">{item.name}</div> <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 text-sm"> <div className="text-text-color/60 mt-4 text-sm">
{item.description} {item.description}
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<Rate value={item.rate || 7} readonly /> <Rate size="small" value={item.rate || 7} readonly />
<div></div> <div></div>
</div> </div>
</div> </div>
</div> </div>
); );
}; });
export default function Novel() { export default function Novel() {
const [params, setParams] = useState({}); const {
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } = dataSource,
useInfiniteQuery<{ rows: any[]; nextPage: number; hasMore: boolean }>({ isFirstLoading,
initialPageParam: 1, isLoadingMore,
queryKey: ['novels', params], noMoreData,
getNextPageParam: (lastPage) => onLoadMore,
lastPage.hasMore ? lastPage.nextPage : undefined, onSearch,
queryFn: ({ pageParam }) => request({ index: pageParam, params }), } = useInfiniteScroll(fetchCharacters, {
}); queryKey: 'characters',
});
const options = [ // 使用useQuery查询tags
{ const { data: tags } = useQuery({
label: 'tag1', queryKey: ['tags'],
value: 'tag1', queryFn: async () => {
const res = await fetchTags();
return res.rows;
}, },
{ });
label: 'tag2',
value: 'tag2',
},
{
label: 'tag3',
value: 'tag3',
},
];
const dataSource = useMemo(() => { const options = tags?.map((tag: any) => ({
return data?.pages?.flatMap((page) => page.rows) || []; label: tag.name,
}, [data]); value: tag.id,
}));
return ( return (
<div className="h-full"> <VirtualGrid
<VirtualGrid padding={{ left: 50, right: 50 }}
padding={{ left: 50, right: 50 }} dataSource={dataSource}
dataSource={dataSource} isFirstLoading={isFirstLoading}
isLoading={isLoading} isLoadingMore={isLoadingMore}
isLoadingMore={isFetchingNextPage} noMoreData={noMoreData}
noMoreData={!hasNextPage} noMoreDataRender={null}
noMoreDataRender={null} loadMore={onLoadMore}
loadMore={() => { header={
fetchNextPage(); <TagSelect
}} options={options}
header={ render={(item) => `# ${item.label}`}
<TagSelect onChange={(v) => {
options={options} onSearch({ tagId: v });
mode="multiple" }}
render={(item) => `# ${item.label}`} className="mx-12.5 my-7.5 mb-7.5"
onChange={(v) => { />
setParams({ ...params, tags: v }); }
}} gap={20}
className="mx-12.5 my-7.5" className="h-full"
/> rowHeight={448}
} keySetter={(item) => item.id}
gap={20} columnCalc={(width) => Math.floor(width / 250)}
className="h-full" itemRender={(item) => <RoleCard item={item} />}
rowHeight={448} />
keySetter={(item) => item.id}
columnCalc={(width) => Math.floor(width / 250)}
itemRender={(item) => <RoleCard item={item} />}
/>
</div>
); );
} }

View File

@ -0,0 +1,10 @@
import request from '@/lib/request';
export async function fetchCharacters({ index, limit, query }: any) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const { data } = await request('/api/character/select/list', {
method: 'POST',
data: { index, limit, ...query },
});
return data;
}

View File

@ -50,7 +50,7 @@ export default function Novel() {
<VirtualGrid <VirtualGrid
padding={{ left: 50, right: 50 }} padding={{ left: 50, right: 50 }}
dataSource={dataSource} dataSource={dataSource}
isLoading={isLoading} isFirstLoading={isLoading}
isLoadingMore={isFetchingNextPage} isLoadingMore={isFetchingNextPage}
noMoreData={!hasNextPage} noMoreData={!hasNextPage}
noMoreDataRender={null} noMoreDataRender={null}

View File

@ -32,6 +32,12 @@ body {
color 0.3s ease; color 0.3s ease;
} }
/* 我期望背景图默认是等比例缩放,铺满容器,居中显示,不重复 */
.cover-bg {
background-size: cover;
background-position: center;
}
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 10px;

View File

@ -6,4 +6,4 @@ export { default as Icon } from './ui/icon';
export { default as Drawer } from './ui/drawer'; export { default as Drawer } from './ui/drawer';
export { default as ModelSelectDialog } from './feature/ModelSelectDialog'; export { default as ModelSelectDialog } from './feature/ModelSelectDialog';
export { default as VirtualGrid } from './ui/VirtualGrid'; export { default as VirtualGrid } from './ui/VirtualGrid';
export { default as TagSelect } from './ui/tag'; export { default as TagSelect } from './ui/TagSelect';

View File

@ -40,7 +40,7 @@ const TagSelect: React.FC<TagSelectProps> = (props) => {
const handleSelect = (option: TagItem) => { const handleSelect = (option: TagItem) => {
if (readonly) return; if (readonly) return;
if (mode === 'single') { if (mode === 'single') {
onChange(option.value, option); onChange(option.value === value ? undefined : option.value, option);
} else { } else {
const newValues = selected.includes(option.value) const newValues = selected.includes(option.value)
? selected.filter((v) => v !== option.value) ? selected.filter((v) => v !== option.value)

View File

@ -0,0 +1,34 @@
'use client';
import React from 'react';
import { cn } from '@/lib';
type TagProps = {
options: {
label: string;
value: string;
color?: string;
}[];
} & React.HTMLAttributes<HTMLDivElement>;
export default function Tags(props: TagProps) {
const { options, ...others } = props;
return (
<div
{...others}
className={cn('flex flex-wrap gap-1.25', others.className)}
>
{options.map((option) => (
<span
key={option.value}
className={cn(
'inline-block h-5.25 rounded-[5px] bg-black/40 px-1.5 text-center text-xs leading-[21px]',
option.color
)}
>
{option.label}
</span>
))}
</div>
);
}

View File

@ -1,8 +1,7 @@
'use client';
import type React from 'react'; import type React from 'react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { useDebounceFn, useUpdate } from 'ahooks'; import { useDebounceFn, useUpdate } from 'ahooks';
import cloneDeep from 'lodash/cloneDeep';
import { is } from 'zod/locales';
type VirtualGridProps<T extends any = any> = { type VirtualGridProps<T extends any = any> = {
rowHeight?: number; rowHeight?: number;
@ -11,7 +10,7 @@ type VirtualGridProps<T extends any = any> = {
noMoreData?: boolean; noMoreData?: boolean;
noMoreDataRender?: (() => React.ReactNode) | null; noMoreDataRender?: (() => React.ReactNode) | null;
isLoadingMore?: boolean; isLoadingMore?: boolean;
isLoading?: boolean; isFirstLoading?: boolean;
loadMore?: () => void; loadMore?: () => void;
gap?: number; gap?: number;
padding?: { padding?: {
@ -19,11 +18,31 @@ type VirtualGridProps<T extends any = any> = {
left?: number; left?: number;
}; };
columnCalc?: (width: number) => number; columnCalc?: (width: number) => number;
keySetter?: (item: T) => string; keySetter?: (item: T) => string | number;
preRenderHeight?: number; preRenderHeight?: number;
header?: React.ReactNode; header?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>; } & React.HTMLAttributes<HTMLDivElement>;
const useResizeObserver = (
ref: React.RefObject<HTMLDivElement | null>,
callback: () => void
) => {
useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
callback();
});
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
if (ref.current) {
resizeObserver.unobserve(ref.current);
}
resizeObserver.disconnect();
};
}, []);
};
function VirtualGrid<T extends any = any>( function VirtualGrid<T extends any = any>(
props: VirtualGridProps<T> props: VirtualGridProps<T>
): React.ReactNode { ): React.ReactNode {
@ -40,15 +59,8 @@ function VirtualGrid<T extends any = any>(
preRenderHeight = 100, preRenderHeight = 100,
padding, padding,
isLoadingMore, isLoadingMore,
isLoading, isFirstLoading,
noMoreDataRender = () => ( noMoreDataRender = () => 'No more data',
<div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
No more data
</div>
),
...others ...others
} = props; } = props;
const { left = 0, right = 0 } = padding || {}; const { left = 0, right = 0 } = padding || {};
@ -130,19 +142,20 @@ function VirtualGrid<T extends any = any>(
scrollState.current.viewHeight + scrollState.current.viewHeight +
preRenderHeight!; preRenderHeight!;
const newRenderList = []; const newRenderList = [];
// 注意这里需要遍历dataSource的长度而不是queueState的长度
// 注意这里只针对dataSource长度进行遍历而不是queueState长度
for (let i = 0; i < dataSource.length; i++) { for (let i = 0; i < dataSource.length; i++) {
if ( if (
queueState.current[i].y + rowHeight! >= finalStart && queueState.current[i].y + rowHeight! >= finalStart &&
queueState.current[i].y <= finalEnd queueState.current[i].y <= finalEnd
) { ) {
newRenderList.push(cloneDeep(queueState.current[i])); // 注意这里需要浅拷贝, 不然resize的时候会直接更新到renderList, 导致下面计算条件update不生效
newRenderList.push({ ...queueState.current[i] });
} }
} }
if ( if (
newRenderList.length !== renderList.current.length || newRenderList.length !== renderList.current.length ||
newRenderList[0]?.y !== renderList.current[0]?.y || newRenderList[0]?.y !== renderList.current[0]?.y
newRenderList[0]?.style.width !== renderList.current[0]?.style.width
) { ) {
update(); update();
} }
@ -204,9 +217,8 @@ function VirtualGrid<T extends any = any>(
const updateQueueData = () => { const updateQueueData = () => {
for (let i = 0; i < dataSource.length; i++) { for (let i = 0; i < dataSource.length; i++) {
const item = dataSource[i]; const item = dataSource[i];
// 如果是已经计算过,则只更新数据
if (queueState.current[i]) { if (queueState.current[i]) {
queueState.current[i].item = dataSource[i]; queueState.current[i].item = item;
continue; continue;
} }
@ -228,7 +240,7 @@ function VirtualGrid<T extends any = any>(
if (!containerRef.current) return; if (!containerRef.current) return;
scrollState.current.start = containerRef.current.scrollTop; scrollState.current.start = containerRef.current.scrollTop;
genereateRenderList(); genereateRenderList();
if (noMoreData || isLoadingMore || isLoading) return; if (noMoreData || isLoadingMore || isFirstLoading) return;
const { scrollTop, clientHeight } = containerRef.current; const { scrollTop, clientHeight } = containerRef.current;
if (scrollTop + clientHeight >= listHeight.current) { if (scrollTop + clientHeight >= listHeight.current) {
loadMore?.(); loadMore?.();
@ -247,24 +259,8 @@ function VirtualGrid<T extends any = any>(
); );
// 监听容器和header的尺寸变化 // 监听容器和header的尺寸变化
useEffect(() => { useResizeObserver(containerRef, handleResize);
const resizeObserver = new ResizeObserver(() => { useResizeObserver(headerRef, handleResize);
handleResize();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
resizeObserver.observe(headerRef.current!);
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
resizeObserver.unobserve(headerRef.current!);
}
resizeObserver.disconnect();
};
}, []);
// 当 dataSource 变化时,重新计算布局 // 当 dataSource 变化时,重新计算布局
useEffect(() => { useEffect(() => {
@ -272,16 +268,22 @@ function VirtualGrid<T extends any = any>(
if (!scrollState.current.viewWidth) { if (!scrollState.current.viewWidth) {
initScrollState(); initScrollState();
} }
// 添加到队列
updateQueueData(); updateQueueData();
}, [dataSource]); }, [dataSource]);
// 插槽高度,用于 loading 和 no more data
const loadingHeight = 20; const loadingHeight = 20;
return ( return (
<div <div
ref={containerRef} ref={containerRef}
{...others} {...others}
className={`overflow-auto ${others.className}`} className={`relative overflow-auto ${others.className}`}
style={{
scrollbarGutter: 'stable', // 为滚动条预留空间,防止布局抖动
...others.style,
}}
onScroll={handleScroll} onScroll={handleScroll}
> >
<div <div
@ -300,21 +302,29 @@ function VirtualGrid<T extends any = any>(
{itemRender?.(item)} {itemRender?.(item)}
</div> </div>
))} ))}
{/* 加载更多 */}
{!!renderList.current.length && !noMoreData && ( {!!renderList.current.length && !noMoreData && (
<div <div
className="absolute flex w-full items-center justify-center" className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }} style={{ top: listHeight.current, height: loadingHeight }}
> >
Loading more... {isLoadingMore ? 'Loading more...' : 'load more'}
</div> </div>
)} )}
{noMoreData && noMoreDataRender?.()}
{isLoadingMore && ( {/* 没有更多数据 */}
<div className="absolute flex w-full items-center justify-center"> {noMoreData && noMoreDataRender ? (
Loading... <div
className="absolute flex w-full items-center justify-center"
style={{ top: listHeight.current, height: loadingHeight }}
>
{noMoreDataRender()}
</div> </div>
)} ) : null}
{!renderList.current.length && !isLoading && (
{/* 没有数据 */}
{!renderList.current.length && !isFirstLoading && (
<div className="absolute flex w-full items-center justify-center"> <div className="absolute flex w-full items-center justify-center">
No data No data
</div> </div>

View File

@ -14,6 +14,7 @@ type DrawerProps = {
zIndex?: number | string; zIndex?: number | string;
}; };
// 一个抽屉组件支持打开关闭动画和自定义位置、宽度、z-index **无样式**
export default function Drawer({ export default function Drawer({
open = false, open = false,
getContainer, getContainer,

View File

@ -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 { Select as SelectPrimitive } from 'radix-ui'; import * as SelectPrimitive from '@radix-ui/react-select';
import { useControllableValue } from 'ahooks'; import { useControllableValue } from 'ahooks';
import { InputLeft } from '.'; import { InputLeft } from '.';
@ -92,7 +92,7 @@ function Select(props: SelectProps) {
<Portal> <Portal>
<Content <Content
className={cn( className={cn(
'rounded-[20px] border border-white/10', 'rounded-[20] border border-white/10',
'overflow-hidden', 'overflow-hidden',
contentClassName contentClassName
)} )}

View File

@ -3,7 +3,7 @@
import { cn } from '@/lib'; import { cn } from '@/lib';
import { useControllableValue } from 'ahooks'; import { useControllableValue } from 'ahooks';
import { InputLeft } from '.'; import { InputLeft } from '.';
import { Switch as SwitchPrimitive } from 'radix-ui'; import * as SwitchPrimitive from '@radix-ui/react-switch';
const { Root, Thumb } = SwitchPrimitive; const { Root, Thumb } = SwitchPrimitive;
type SwitchProps = { type SwitchProps = {
value?: boolean; value?: boolean;

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useControllableValue } from 'ahooks'; import { useControllableValue } from 'ahooks';
import { Dialog as DialogPrimitive } from 'radix-ui'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import './index.css'; import './index.css';
import Image from 'next/image'; import Image from 'next/image';
import { cn } from '@/lib'; import { cn } from '@/lib';

View File

@ -8,13 +8,16 @@ type RateProps = {
readonly?: boolean; readonly?: boolean;
// 0 - 10 // 0 - 10
value?: number; value?: number;
size?: 'default' | 'small';
onChange?: (value: number) => void; onChange?: (value: number) => void;
} & React.HTMLAttributes<HTMLDivElement>; } & React.HTMLAttributes<HTMLDivElement>;
export default function Rate(props: RateProps) { export default function Rate(props: RateProps) {
const { readonly = false, ...others } = props; const { readonly = false, size = 'default', ...others } = props;
const [value = 0, onChange] = useControllableValue(props); const [value = 0, onChange] = useControllableValue(props);
const [hoveredValue, setHoveredValue] = useState(0); const [hoveredValue, setHoveredValue] = useState(0);
const width = size === 'default' ? 20 : 16;
const readonlyRender = () => { const readonlyRender = () => {
// value 范围 0-10每颗星代表 2 分 // value 范围 0-10每颗星代表 2 分
const fullCounts = Math.floor(value / 2); // 满星数量 const fullCounts = Math.floor(value / 2); // 满星数量
@ -27,8 +30,8 @@ export default function Rate(props: RateProps) {
<Image <Image
key={`full-${i}`} key={`full-${i}`}
src={'/component/star_full.svg'} src={'/component/star_full.svg'}
width={20} width={width}
height={20} height={width}
alt="full star" alt="full star"
/> />
)), )),
@ -37,8 +40,8 @@ export default function Rate(props: RateProps) {
<Image <Image
key={`half-${i}`} key={`half-${i}`}
src={'/component/star_half.svg'} src={'/component/star_half.svg'}
width={20} width={width}
height={20} height={width}
alt="half star" alt="half star"
/> />
)), )),
@ -71,8 +74,8 @@ export default function Rate(props: RateProps) {
<Image <Image
key={index} key={index}
src={src} src={src}
width={20} width={width}
height={20} height={width}
alt="star" alt="star"
className="cursor-pointer" className="cursor-pointer"
onClick={() => onChange?.((index + 1) * 2)} onClick={() => onChange?.((index + 1) * 2)}

View File

@ -0,0 +1,120 @@
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
type ParamsType<Q = any> = {
index: number;
limit: number;
query: Q;
};
type PropsType<Q = any> = {
queryKey: string;
defaultQuery?: Q;
defaultIndex?: number;
limit?: number;
enabled?: boolean; // 是否启用查询
};
type RequestType<T = any, Q = any> = (
params: ParamsType<Q>
) => Promise<{ rows: T[]; total: number } | undefined>;
type UseInfiniteScrollValue<T = any, Q = any> = {
query: Q;
onLoadMore: () => void;
onSearch: (query: Q) => void;
dataSource: T[];
total: number;
// 是否正在加载第一页,包括 初始化加载 和 参数改变时加载
isFirstLoading: boolean;
isLoadingMore: boolean;
refresh: () => void;
noMoreData: boolean;
error: Error | null;
};
export const useInfiniteScroll = <T = any, Q = any>(
request: RequestType<T, Q>,
props: PropsType<Q>
): UseInfiniteScrollValue<T, Q> => {
const {
queryKey,
defaultQuery,
defaultIndex = 1,
limit = 20,
enabled = true,
} = props;
const [query, setQuery] = useState<Q>(defaultQuery as Q);
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isFetchingNextPage,
isLoading,
refetch,
error,
} = useInfiniteQuery({
queryKey: [queryKey, query],
queryFn: async ({ pageParam = 1 }) => {
if (!request) {
return { rows: [], total: 0 };
}
const params = {
limit,
index: pageParam,
query,
};
// 修复:添加错误处理
try {
const result = await request(params);
return result || { rows: [], total: 0 };
} catch (err) {
console.error('useInfiniteScroll request error:', err);
throw err; // 让 React Query 处理错误
}
},
getNextPageParam: (lastPage, allPages) => {
const currentTotal = limit * allPages.length;
if (currentTotal < lastPage.total) {
return allPages.length + 1;
}
// 没有下一页
return undefined;
},
initialPageParam: defaultIndex,
enabled: enabled && !!request,
placeholderData: keepPreviousData, // 保留旧数据直到新数据加载完成
});
// 合并所有页面的数据
const dataSource = useMemo(() => {
return data?.pages.flatMap((page) => page.rows || []) || [];
}, [data]);
const total = useMemo(() => {
return data?.pages[0]?.total || 0;
}, [data?.pages]);
const onLoadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
return {
query,
onLoadMore,
onSearch: setQuery,
dataSource,
total,
isFirstLoading: isLoading || (isFetching && !isFetchingNextPage),
isLoadingMore: isFetchingNextPage,
refresh: () => refetch(),
noMoreData: !hasNextPage,
error: error as Error | null,
};
};

View File

@ -8,14 +8,9 @@ export default function MainLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="h-screen w-screen overflow-auto"> <div className="flex h-screen w-screen flex-col overflow-auto">
<Header /> <Header />
<div <div className="w-full flex-1 overflow-auto">{children}</div>
style={{ height: 'calc(100vh - 60px)' }}
className="w-full overflow-auto"
>
{children}
</div>
</div> </div>
); );
} }

View File

@ -1,138 +1,13 @@
import Cookies from 'js-cookie'; const authInfoKey = 'auth';
const TOKEN_COOKIE_NAME = 'st'; export const saveAuthInfo = (authInfo: any) => {
const DEVICE_ID_COOKIE_NAME = 'sd'; localStorage.setItem(authInfoKey, JSON.stringify(authInfo));
// 生成设备ID的函数
function generateDeviceId(): string {
const timestamp = Date.now().toString(36);
const randomStr = Math.random().toString(36).substring(2, 15);
const browserInfo =
typeof window !== 'undefined'
? `${window.navigator.userAgent}${window.screen.width}${window.screen.height}`
.replace(/\s/g, '')
.substring(0, 10)
: 'server';
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase();
}
export const authManager = {
// 获取token - 支持客户端和服务端
getToken: (cookieString?: string): string | null => {
// 服务端环境从传入的cookie字符串中解析
if (typeof window === 'undefined' && cookieString) {
const cookies = parseCookieString(cookieString);
return cookies[TOKEN_COOKIE_NAME] || null;
}
// 客户端环境从document.cookie或localStorage获取
if (typeof window !== 'undefined') {
// 优先从cookie获取
const cookieToken = Cookies.get(TOKEN_COOKIE_NAME);
if (cookieToken) {
return cookieToken;
}
}
return null;
},
// 获取设备ID - 支持客户端和服务端
getDeviceId: (cookieString?: string): string => {
// 服务端环境从传入的cookie字符串中解析
if (typeof window === 'undefined' && cookieString) {
const cookies = parseCookieString(cookieString);
let deviceId = cookies[DEVICE_ID_COOKIE_NAME];
// 如果服务端没有设备ID生成一个临时的
if (!deviceId) {
deviceId = generateDeviceId();
}
return deviceId;
}
// 客户端环境
if (typeof window !== 'undefined') {
let deviceId = Cookies.get(DEVICE_ID_COOKIE_NAME);
// 如果没有设备ID生成一个新的
if (!deviceId) {
deviceId = generateDeviceId();
authManager.setDeviceId(deviceId);
}
return deviceId;
}
// 兜底情况生成临时设备ID
return generateDeviceId();
},
// 设置token
setToken: (token: string): void => {
if (typeof window !== 'undefined') {
// 设置cookie30天过期
Cookies.set(TOKEN_COOKIE_NAME, token, {
expires: 30,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
}
},
// 设置设备ID
setDeviceId: (deviceId: string): void => {
if (typeof window !== 'undefined') {
// 设置cookie365天过期设备ID应该长期保存
Cookies.set(DEVICE_ID_COOKIE_NAME, deviceId, {
expires: 365,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
});
}
},
// 清除token但保留设备ID
removeToken: (): void => {
if (typeof window !== 'undefined') {
Cookies.remove(TOKEN_COOKIE_NAME);
// 注意这里不清除设备ID
}
},
// 清除所有数据包括设备ID
clearAll: (): void => {
if (typeof window !== 'undefined') {
Cookies.remove(TOKEN_COOKIE_NAME);
Cookies.remove(DEVICE_ID_COOKIE_NAME);
}
},
// 检查是否已登录
isAuthenticated: (cookieString?: string): boolean => {
return !!authManager.getToken(cookieString);
},
// 初始化设备ID确保用户第一次访问时就有设备ID
initializeDeviceId: (): void => {
if (typeof window !== 'undefined') {
authManager.getDeviceId(); // 这会自动生成并保存设备ID如果不存在的话
}
},
}; };
// 解析cookie字符串的辅助函数 export const getAuthInfo = () => {
function parseCookieString(cookieString: string): Record<string, string> { return JSON.parse(localStorage.getItem(authInfoKey) || 'null');
const cookies: Record<string, string> = {}; };
cookieString.split(';').forEach((cookie) => { export const getToken = () => {
const [name, ...rest] = cookie.trim().split('='); return getAuthInfo()?.token || null;
if (name) { };
cookies[name] = rest.join('=');
}
});
return cookies;
}

View File

@ -1,157 +1,89 @@
import axios, { import type {
type AxiosInstance, AxiosRequestConfig,
type CreateAxiosDefaults, AxiosResponse,
type AxiosRequestConfig, InternalAxiosRequestConfig,
} from 'axios'; } from 'axios';
import { authManager } from '@/lib/auth'; import axios from 'axios';
import type { ApiResponse } from '@/types/api'; import { getToken, saveAuthInfo } from './auth';
import { API_STATUS, ApiError } from '@/types/api';
// 扩展 AxiosRequestConfig 以支持 ignoreError 选项 const instance = axios.create({
export interface ExtendedAxiosRequestConfig extends AxiosRequestConfig { withCredentials: false,
ignoreError?: boolean; baseURL: '/',
} validateStatus: (status) => {
return status >= 200 && status < 500;
},
});
// // 扩展 AxiosInstance 类型以支持带有 ignoreError 的请求 instance.interceptors.request.use(
// export interface ExtendedAxiosInstance extends AxiosInstance { async (config: InternalAxiosRequestConfig) => {
// post<T = any, R = T, D = any>( const token = await getToken();
// url: string, if (token) {
// data?: D, config.headers.setAuthorization(`Bearer ${token}`);
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// get<T = any, R = T>(
// url: string,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// put<T = any, R = T, D = any>(
// url: string,
// data?: D,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// delete<T = any, R = T>(
// url: string,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// patch<T = any, R = T, D = any>(
// url: string,
// data?: D,
// config?: ExtendedAxiosRequestConfig
// ): Promise<R>;
// }
export interface CreateHttpClientConfig extends CreateAxiosDefaults {
serviceName: string;
cookieString?: string; // 用于服务端渲染时传递cookie
showErrorToast?: boolean; // 是否自动显示错误提示默认为true
}
export function createHttpClient(config: CreateHttpClientConfig) {
const {
serviceName,
cookieString,
showErrorToast = true,
...axiosConfig
} = config;
const instance = axios.create({
baseURL: '/',
timeout: 120000,
headers: {
'Content-Type': 'application/json',
Platform: 'web',
},
...axiosConfig,
});
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// 获取token - 支持服务端和客户端
const token = authManager.getToken(cookieString);
if (token) {
// Java后端使用AUTH_TK字段接收token
config.headers['AUTH_TK'] = token;
}
// 获取设备ID - 支持服务端和客户端
const deviceId = authManager.getDeviceId(cookieString);
if (deviceId) {
// Java后端使用AUTH_DID字段接收设备ID
config.headers['AUTH_DID'] = deviceId;
}
// 添加服务标识
config.headers['X-Service'] = serviceName;
// 服务端渲染时传递cookie
if (typeof window === 'undefined' && cookieString) {
config.headers.Cookie = cookieString;
}
return config;
},
(error) => {
return Promise.reject(error);
} }
); return config;
}
);
// 响应拦截器 instance.interceptors.response.use(
instance.interceptors.response.use( async (response: AxiosResponse): Promise<AxiosResponse> => {
(response) => { if (response.status >= 400 && response.status < 500) {
const apiResponse = response.data as ApiResponse; if (response.status === 401) {
// message.error('401登录过期');
// 检查业务状态 // saveAuthInfo(null);
if (apiResponse.status === API_STATUS.OK) { // setTimeout(() => {
// 成功返回content内容 // window.location.href = '/login';
return apiResponse.content; // }, 3000);
} else { } else {
// 检查是否忽略错误 // message.error(`${response.status}${response.statusText}`);
const ignoreError = (response.config as ExtendedAxiosRequestConfig)
?.ignoreError;
// 业务错误创建ApiError并抛出
const apiError = new ApiError(
apiResponse.errorCode,
apiResponse.errorMsg,
apiResponse.traceId,
!!ignoreError
);
// 错误提示由providers.tsx中的全局错误处理统一管理
// 这里不再直接显示toast避免重复弹窗
return Promise.reject(apiError);
} }
},
(error) => {
// 检查是否忽略错误
const ignoreError = (error.config as ExtendedAxiosRequestConfig)
?.ignoreError;
// 网络错误或其他HTTP错误
let errorMessage = 'Network exception, please try again later';
let errorCode = 'NETWORK_ERROR';
// 创建标准化的错误对象
const traceId = error.response?.headers?.['x-trace-id'] || 'unknown';
const apiError = new ApiError(
errorCode,
errorMessage,
traceId,
!!ignoreError
);
return Promise.reject(apiError);
} }
);
return instance; if (response.status >= 500) {
} // notification.error({
// message: '网络出现了错误',
// description: response.statusText,
// });
}
// 创建不显示错误提示的HTTP客户端用于静默请求 return response;
export function createSilentHttpClient(serviceName: string) { },
return createHttpClient({ (error) => {
serviceName, console.log('error', error);
showErrorToast: false, if (axios.isCancel(error)) {
return Promise.resolve('请求取消');
}
// notification.error({
// message: '网络出现了错误',
// description: error,
// });
return Promise.reject(error);
}
);
type ResponseType<T = any> = {
code: number;
message: string;
data: T;
};
async function request<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<ResponseType<T>> {
let data: any;
if (config && config?.params) {
const { params } = config;
data = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== '')
);
}
const response = await instance<ResponseType<T>>(url, {
...config,
params: data,
}); });
return response.data;
} }
export default request;

9
src/services/tag.ts Normal file
View File

@ -0,0 +1,9 @@
import request from '@/lib/request';
export async function fetchTags(params: any = {}) {
const { data } = await request('/api/tag/selectByCondition', {
method: 'POST',
data: { limit: 20, ...params },
});
return data;
}