feat: 角色列表页面
This commit is contained in:
parent
a279653321
commit
0cb63f144f
|
|
@ -1,46 +1,18 @@
|
|||
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 = {
|
||||
reactStrictMode: false,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// async rewrites() {
|
||||
// return [
|
||||
// {
|
||||
// source: '/api/frog/:path*',
|
||||
// destination: `${endpoints.frog}/api/frog/: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*`,
|
||||
// },
|
||||
// ];
|
||||
// },
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*', // 前端请求 /api/xxx
|
||||
destination: 'http://54.223.196.180:8091/:path*', // 实际请求到后端服务器
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"ahooks": "^3.9.5",
|
||||
"axios": "^1.12.2",
|
||||
|
|
@ -23,7 +26,6 @@
|
|||
"next": "15.5.4",
|
||||
"next-intl": "^4.3.11",
|
||||
"qs": "^6.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
|
|
|
|||
978
pnpm-lock.yaml
978
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,118 +1,102 @@
|
|||
'use client';
|
||||
import { TagSelect, VirtualGrid, Rate } from '@/components';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
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 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 RoleCard: React.FC<any> = React.memo(({ item }) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
onClick={() => router.push(`/character/${item.id}/review`)}
|
||||
className="relative flex h-full w-full flex-col justify-between rounded-[20px] hover:cursor-pointer"
|
||||
onClick={() => router.push(`/character/${item.id}/chat`)}
|
||||
className="cover-bg relative flex h-full w-full cursor-pointer flex-col justify-between overflow-hidden rounded-[20]"
|
||||
style={{
|
||||
backgroundImage: `url(${item.from || '/test.png'})`,
|
||||
}}
|
||||
>
|
||||
{/* from */}
|
||||
<div>
|
||||
<Image
|
||||
src={item.from || '/test.png'}
|
||||
alt="from"
|
||||
width={55}
|
||||
height={78}
|
||||
/>
|
||||
</div>
|
||||
<Image src={item.from || '/test.png'} alt="from" width={55} height={78} />
|
||||
|
||||
{/* info */}
|
||||
<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">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Rate value={item.rate || 7} readonly />
|
||||
<Rate size="small" value={item.rate || 7} readonly />
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default function Novel() {
|
||||
const [params, setParams] = useState({});
|
||||
const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } =
|
||||
useInfiniteQuery<{ rows: any[]; nextPage: number; hasMore: boolean }>({
|
||||
initialPageParam: 1,
|
||||
queryKey: ['novels', params],
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.hasMore ? lastPage.nextPage : undefined,
|
||||
queryFn: ({ pageParam }) => request({ index: pageParam, params }),
|
||||
});
|
||||
const {
|
||||
dataSource,
|
||||
isFirstLoading,
|
||||
isLoadingMore,
|
||||
noMoreData,
|
||||
onLoadMore,
|
||||
onSearch,
|
||||
} = useInfiniteScroll(fetchCharacters, {
|
||||
queryKey: 'characters',
|
||||
});
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: 'tag1',
|
||||
value: 'tag1',
|
||||
// 使用useQuery查询tags
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: async () => {
|
||||
const res = await fetchTags();
|
||||
return res.rows;
|
||||
},
|
||||
{
|
||||
label: 'tag2',
|
||||
value: 'tag2',
|
||||
},
|
||||
{
|
||||
label: 'tag3',
|
||||
value: 'tag3',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const dataSource = useMemo(() => {
|
||||
return data?.pages?.flatMap((page) => page.rows) || [];
|
||||
}, [data]);
|
||||
const options = tags?.map((tag: any) => ({
|
||||
label: tag.name,
|
||||
value: tag.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<VirtualGrid
|
||||
padding={{ left: 50, right: 50 }}
|
||||
dataSource={dataSource}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={isFetchingNextPage}
|
||||
noMoreData={!hasNextPage}
|
||||
noMoreDataRender={null}
|
||||
loadMore={() => {
|
||||
fetchNextPage();
|
||||
}}
|
||||
header={
|
||||
<TagSelect
|
||||
options={options}
|
||||
mode="multiple"
|
||||
render={(item) => `# ${item.label}`}
|
||||
onChange={(v) => {
|
||||
setParams({ ...params, tags: v });
|
||||
}}
|
||||
className="mx-12.5 my-7.5"
|
||||
/>
|
||||
}
|
||||
gap={20}
|
||||
className="h-full"
|
||||
rowHeight={448}
|
||||
keySetter={(item) => item.id}
|
||||
columnCalc={(width) => Math.floor(width / 250)}
|
||||
itemRender={(item) => <RoleCard item={item} />}
|
||||
/>
|
||||
</div>
|
||||
<VirtualGrid
|
||||
padding={{ left: 50, right: 50 }}
|
||||
dataSource={dataSource}
|
||||
isFirstLoading={isFirstLoading}
|
||||
isLoadingMore={isLoadingMore}
|
||||
noMoreData={noMoreData}
|
||||
noMoreDataRender={null}
|
||||
loadMore={onLoadMore}
|
||||
header={
|
||||
<TagSelect
|
||||
options={options}
|
||||
render={(item) => `# ${item.label}`}
|
||||
onChange={(v) => {
|
||||
onSearch({ tagId: v });
|
||||
}}
|
||||
className="mx-12.5 my-7.5 mb-7.5"
|
||||
/>
|
||||
}
|
||||
gap={20}
|
||||
className="h-full"
|
||||
rowHeight={448}
|
||||
keySetter={(item) => item.id}
|
||||
columnCalc={(width) => Math.floor(width / 250)}
|
||||
itemRender={(item) => <RoleCard item={item} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -50,7 +50,7 @@ export default function Novel() {
|
|||
<VirtualGrid
|
||||
padding={{ left: 50, right: 50 }}
|
||||
dataSource={dataSource}
|
||||
isLoading={isLoading}
|
||||
isFirstLoading={isLoading}
|
||||
isLoadingMore={isFetchingNextPage}
|
||||
noMoreData={!hasNextPage}
|
||||
noMoreDataRender={null}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ body {
|
|||
color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 我期望背景图默认是等比例缩放,铺满容器,居中显示,不重复 */
|
||||
.cover-bg {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ export { default as Icon } from './ui/icon';
|
|||
export { default as Drawer } from './ui/drawer';
|
||||
export { default as ModelSelectDialog } from './feature/ModelSelectDialog';
|
||||
export { default as VirtualGrid } from './ui/VirtualGrid';
|
||||
export { default as TagSelect } from './ui/tag';
|
||||
export { default as TagSelect } from './ui/TagSelect';
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const TagSelect: React.FC<TagSelectProps> = (props) => {
|
|||
const handleSelect = (option: TagItem) => {
|
||||
if (readonly) return;
|
||||
if (mode === 'single') {
|
||||
onChange(option.value, option);
|
||||
onChange(option.value === value ? undefined : option.value, option);
|
||||
} else {
|
||||
const newValues = selected.includes(option.value)
|
||||
? selected.filter((v) => v !== option.value)
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
'use client';
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDebounceFn, useUpdate } from 'ahooks';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { is } from 'zod/locales';
|
||||
|
||||
type VirtualGridProps<T extends any = any> = {
|
||||
rowHeight?: number;
|
||||
|
|
@ -11,7 +10,7 @@ type VirtualGridProps<T extends any = any> = {
|
|||
noMoreData?: boolean;
|
||||
noMoreDataRender?: (() => React.ReactNode) | null;
|
||||
isLoadingMore?: boolean;
|
||||
isLoading?: boolean;
|
||||
isFirstLoading?: boolean;
|
||||
loadMore?: () => void;
|
||||
gap?: number;
|
||||
padding?: {
|
||||
|
|
@ -19,11 +18,31 @@ type VirtualGridProps<T extends any = any> = {
|
|||
left?: number;
|
||||
};
|
||||
columnCalc?: (width: number) => number;
|
||||
keySetter?: (item: T) => string;
|
||||
keySetter?: (item: T) => string | number;
|
||||
preRenderHeight?: number;
|
||||
header?: React.ReactNode;
|
||||
} & 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>(
|
||||
props: VirtualGridProps<T>
|
||||
): React.ReactNode {
|
||||
|
|
@ -40,15 +59,8 @@ function VirtualGrid<T extends any = any>(
|
|||
preRenderHeight = 100,
|
||||
padding,
|
||||
isLoadingMore,
|
||||
isLoading,
|
||||
noMoreDataRender = () => (
|
||||
<div
|
||||
className="absolute flex w-full items-center justify-center"
|
||||
style={{ top: listHeight.current, height: loadingHeight }}
|
||||
>
|
||||
No more data
|
||||
</div>
|
||||
),
|
||||
isFirstLoading,
|
||||
noMoreDataRender = () => 'No more data',
|
||||
...others
|
||||
} = props;
|
||||
const { left = 0, right = 0 } = padding || {};
|
||||
|
|
@ -130,19 +142,20 @@ function VirtualGrid<T extends any = any>(
|
|||
scrollState.current.viewHeight +
|
||||
preRenderHeight!;
|
||||
const newRenderList = [];
|
||||
// 注意这里需要遍历dataSource的长度,而不是queueState的长度
|
||||
|
||||
// 注意这里只针对dataSource长度进行遍历,而不是queueState长度
|
||||
for (let i = 0; i < dataSource.length; i++) {
|
||||
if (
|
||||
queueState.current[i].y + rowHeight! >= finalStart &&
|
||||
queueState.current[i].y <= finalEnd
|
||||
) {
|
||||
newRenderList.push(cloneDeep(queueState.current[i]));
|
||||
// 注意这里需要浅拷贝, 不然resize的时候会直接更新到renderList, 导致下面计算条件update不生效
|
||||
newRenderList.push({ ...queueState.current[i] });
|
||||
}
|
||||
}
|
||||
if (
|
||||
newRenderList.length !== renderList.current.length ||
|
||||
newRenderList[0]?.y !== renderList.current[0]?.y ||
|
||||
newRenderList[0]?.style.width !== renderList.current[0]?.style.width
|
||||
newRenderList[0]?.y !== renderList.current[0]?.y
|
||||
) {
|
||||
update();
|
||||
}
|
||||
|
|
@ -204,9 +217,8 @@ function VirtualGrid<T extends any = any>(
|
|||
const updateQueueData = () => {
|
||||
for (let i = 0; i < dataSource.length; i++) {
|
||||
const item = dataSource[i];
|
||||
// 如果是已经计算过,则只更新数据
|
||||
if (queueState.current[i]) {
|
||||
queueState.current[i].item = dataSource[i];
|
||||
queueState.current[i].item = item;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +240,7 @@ function VirtualGrid<T extends any = any>(
|
|||
if (!containerRef.current) return;
|
||||
scrollState.current.start = containerRef.current.scrollTop;
|
||||
genereateRenderList();
|
||||
if (noMoreData || isLoadingMore || isLoading) return;
|
||||
if (noMoreData || isLoadingMore || isFirstLoading) return;
|
||||
const { scrollTop, clientHeight } = containerRef.current;
|
||||
if (scrollTop + clientHeight >= listHeight.current) {
|
||||
loadMore?.();
|
||||
|
|
@ -247,24 +259,8 @@ function VirtualGrid<T extends any = any>(
|
|||
);
|
||||
|
||||
// 监听容器和header的尺寸变化
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
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();
|
||||
};
|
||||
}, []);
|
||||
useResizeObserver(containerRef, handleResize);
|
||||
useResizeObserver(headerRef, handleResize);
|
||||
|
||||
// 当 dataSource 变化时,重新计算布局
|
||||
useEffect(() => {
|
||||
|
|
@ -272,16 +268,22 @@ function VirtualGrid<T extends any = any>(
|
|||
if (!scrollState.current.viewWidth) {
|
||||
initScrollState();
|
||||
}
|
||||
// 添加到队列
|
||||
updateQueueData();
|
||||
}, [dataSource]);
|
||||
|
||||
// 插槽高度,用于 loading 和 no more data
|
||||
const loadingHeight = 20;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
{...others}
|
||||
className={`overflow-auto ${others.className}`}
|
||||
className={`relative overflow-auto ${others.className}`}
|
||||
style={{
|
||||
scrollbarGutter: 'stable', // 为滚动条预留空间,防止布局抖动
|
||||
...others.style,
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div
|
||||
|
|
@ -300,21 +302,29 @@ function VirtualGrid<T extends any = any>(
|
|||
{itemRender?.(item)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多 */}
|
||||
{!!renderList.current.length && !noMoreData && (
|
||||
<div
|
||||
className="absolute flex w-full items-center justify-center"
|
||||
style={{ top: listHeight.current, height: loadingHeight }}
|
||||
>
|
||||
Loading more...
|
||||
{isLoadingMore ? 'Loading more...' : 'load more'}
|
||||
</div>
|
||||
)}
|
||||
{noMoreData && noMoreDataRender?.()}
|
||||
{isLoadingMore && (
|
||||
<div className="absolute flex w-full items-center justify-center">
|
||||
Loading...
|
||||
|
||||
{/* 没有更多数据 */}
|
||||
{noMoreData && noMoreDataRender ? (
|
||||
<div
|
||||
className="absolute flex w-full items-center justify-center"
|
||||
style={{ top: listHeight.current, height: loadingHeight }}
|
||||
>
|
||||
{noMoreDataRender()}
|
||||
</div>
|
||||
)}
|
||||
{!renderList.current.length && !isLoading && (
|
||||
) : null}
|
||||
|
||||
{/* 没有数据 */}
|
||||
{!renderList.current.length && !isFirstLoading && (
|
||||
<div className="absolute flex w-full items-center justify-center">
|
||||
No data
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type DrawerProps = {
|
|||
zIndex?: number | string;
|
||||
};
|
||||
|
||||
// 一个抽屉组件,支持打开关闭动画和自定义位置、宽度、z-index **无样式**
|
||||
export default function Drawer({
|
||||
open = false,
|
||||
getContainer,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { cn } from '@/lib';
|
|||
import React, { useState } from 'react';
|
||||
import rightIcon from '@/assets/components/go_right.svg';
|
||||
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 { InputLeft } from '.';
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ function Select(props: SelectProps) {
|
|||
<Portal>
|
||||
<Content
|
||||
className={cn(
|
||||
'rounded-[20px] border border-white/10',
|
||||
'rounded-[20] border border-white/10',
|
||||
'overflow-hidden',
|
||||
contentClassName
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { cn } from '@/lib';
|
||||
import { useControllableValue } from 'ahooks';
|
||||
import { InputLeft } from '.';
|
||||
import { Switch as SwitchPrimitive } from 'radix-ui';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
const { Root, Thumb } = SwitchPrimitive;
|
||||
type SwitchProps = {
|
||||
value?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { useControllableValue } from 'ahooks';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import './index.css';
|
||||
import Image from 'next/image';
|
||||
import { cn } from '@/lib';
|
||||
|
|
|
|||
|
|
@ -8,13 +8,16 @@ type RateProps = {
|
|||
readonly?: boolean;
|
||||
// 0 - 10
|
||||
value?: number;
|
||||
size?: 'default' | 'small';
|
||||
onChange?: (value: number) => void;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
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 [hoveredValue, setHoveredValue] = useState(0);
|
||||
|
||||
const width = size === 'default' ? 20 : 16;
|
||||
|
||||
const readonlyRender = () => {
|
||||
// value 范围 0-10,每颗星代表 2 分
|
||||
const fullCounts = Math.floor(value / 2); // 满星数量
|
||||
|
|
@ -27,8 +30,8 @@ export default function Rate(props: RateProps) {
|
|||
<Image
|
||||
key={`full-${i}`}
|
||||
src={'/component/star_full.svg'}
|
||||
width={20}
|
||||
height={20}
|
||||
width={width}
|
||||
height={width}
|
||||
alt="full star"
|
||||
/>
|
||||
)),
|
||||
|
|
@ -37,8 +40,8 @@ export default function Rate(props: RateProps) {
|
|||
<Image
|
||||
key={`half-${i}`}
|
||||
src={'/component/star_half.svg'}
|
||||
width={20}
|
||||
height={20}
|
||||
width={width}
|
||||
height={width}
|
||||
alt="half star"
|
||||
/>
|
||||
)),
|
||||
|
|
@ -71,8 +74,8 @@ export default function Rate(props: RateProps) {
|
|||
<Image
|
||||
key={index}
|
||||
src={src}
|
||||
width={20}
|
||||
height={20}
|
||||
width={width}
|
||||
height={width}
|
||||
alt="star"
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChange?.((index + 1) * 2)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -8,14 +8,9 @@ export default function MainLayout({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-auto">
|
||||
<div className="flex h-screen w-screen flex-col overflow-auto">
|
||||
<Header />
|
||||
<div
|
||||
style={{ height: 'calc(100vh - 60px)' }}
|
||||
className="w-full overflow-auto"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="w-full flex-1 overflow-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
143
src/lib/auth.ts
143
src/lib/auth.ts
|
|
@ -1,138 +1,13 @@
|
|||
import Cookies from 'js-cookie';
|
||||
const authInfoKey = 'auth';
|
||||
|
||||
const TOKEN_COOKIE_NAME = 'st';
|
||||
const DEVICE_ID_COOKIE_NAME = 'sd';
|
||||
|
||||
// 生成设备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') {
|
||||
// 设置cookie,30天过期
|
||||
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') {
|
||||
// 设置cookie,365天过期(设备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(如果不存在的话)
|
||||
}
|
||||
},
|
||||
export const saveAuthInfo = (authInfo: any) => {
|
||||
localStorage.setItem(authInfoKey, JSON.stringify(authInfo));
|
||||
};
|
||||
|
||||
// 解析cookie字符串的辅助函数
|
||||
function parseCookieString(cookieString: string): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
export const getAuthInfo = () => {
|
||||
return JSON.parse(localStorage.getItem(authInfoKey) || 'null');
|
||||
};
|
||||
|
||||
cookieString.split(';').forEach((cookie) => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
if (name) {
|
||||
cookies[name] = rest.join('=');
|
||||
}
|
||||
});
|
||||
|
||||
return cookies;
|
||||
}
|
||||
export const getToken = () => {
|
||||
return getAuthInfo()?.token || null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,157 +1,89 @@
|
|||
import axios, {
|
||||
type AxiosInstance,
|
||||
type CreateAxiosDefaults,
|
||||
type AxiosRequestConfig,
|
||||
import type {
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
import { authManager } from '@/lib/auth';
|
||||
import type { ApiResponse } from '@/types/api';
|
||||
import { API_STATUS, ApiError } from '@/types/api';
|
||||
import axios from 'axios';
|
||||
import { getToken, saveAuthInfo } from './auth';
|
||||
|
||||
// 扩展 AxiosRequestConfig 以支持 ignoreError 选项
|
||||
export interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
|
||||
ignoreError?: boolean;
|
||||
}
|
||||
const instance = axios.create({
|
||||
withCredentials: false,
|
||||
baseURL: '/',
|
||||
validateStatus: (status) => {
|
||||
return status >= 200 && status < 500;
|
||||
},
|
||||
});
|
||||
|
||||
// // 扩展 AxiosInstance 类型以支持带有 ignoreError 的请求
|
||||
// export interface ExtendedAxiosInstance extends AxiosInstance {
|
||||
// post<T = any, R = T, D = any>(
|
||||
// url: string,
|
||||
// data?: D,
|
||||
// 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);
|
||||
instance.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
config.headers.setAuthorization(`Bearer ${token}`);
|
||||
}
|
||||
);
|
||||
return config;
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response) => {
|
||||
const apiResponse = response.data as ApiResponse;
|
||||
|
||||
// 检查业务状态
|
||||
if (apiResponse.status === API_STATUS.OK) {
|
||||
// 成功:返回content内容
|
||||
return apiResponse.content;
|
||||
instance.interceptors.response.use(
|
||||
async (response: AxiosResponse): Promise<AxiosResponse> => {
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
if (response.status === 401) {
|
||||
// message.error('401:登录过期');
|
||||
// saveAuthInfo(null);
|
||||
// setTimeout(() => {
|
||||
// window.location.href = '/login';
|
||||
// }, 3000);
|
||||
} else {
|
||||
// 检查是否忽略错误
|
||||
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);
|
||||
// message.error(`${response.status}:${response.statusText}`);
|
||||
}
|
||||
},
|
||||
(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客户端(用于静默请求)
|
||||
export function createSilentHttpClient(serviceName: string) {
|
||||
return createHttpClient({
|
||||
serviceName,
|
||||
showErrorToast: false,
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.log('error', error);
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue