feat: 角色列表页面
This commit is contained in:
parent
a279653321
commit
0cb63f144f
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
978
pnpm-lock.yaml
978
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
<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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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';
|
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') {
|
|
||||||
// 设置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(如果不存在的话)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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