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';
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;

View File

@ -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",

File diff suppressed because it is too large Load Diff

View File

@ -1,109 +1,94 @@
'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}
isFirstLoading={isFirstLoading}
isLoadingMore={isLoadingMore}
noMoreData={noMoreData}
noMoreDataRender={null}
loadMore={() => {
fetchNextPage();
}}
loadMore={onLoadMore}
header={
<TagSelect
options={options}
mode="multiple"
render={(item) => `# ${item.label}`}
onChange={(v) => {
setParams({ ...params, tags: v });
onSearch({ tagId: v });
}}
className="mx-12.5 my-7.5"
className="mx-12.5 my-7.5 mb-7.5"
/>
}
gap={20}
@ -113,6 +98,5 @@ export default function Novel() {
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
padding={{ left: 50, right: 50 }}
dataSource={dataSource}
isLoading={isLoading}
isFirstLoading={isLoading}
isLoadingMore={isFetchingNextPage}
noMoreData={!hasNextPage}
noMoreDataRender={null}

View File

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

View File

@ -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)

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 { 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>

View File

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

View File

@ -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
)}

View File

@ -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;

View File

@ -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';

View File

@ -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)}

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;
}) {
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>
);
}

View File

@ -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') {
// 设置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如果不存在的话
}
},
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;
};

View File

@ -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;
}
// // 扩展 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({
const instance = axios.create({
withCredentials: false,
baseURL: '/',
timeout: 120000,
headers: {
'Content-Type': 'application/json',
Platform: 'web',
validateStatus: (status) => {
return status >= 200 && status < 500;
},
...axiosConfig,
});
// 请求拦截器
instance.interceptors.request.use(
(config) => {
// 获取token - 支持服务端和客户端
const token = authManager.getToken(cookieString);
});
instance.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const token = await getToken();
if (token) {
// Java后端使用AUTH_TK字段接收token
config.headers['AUTH_TK'] = token;
config.headers.setAuthorization(`Bearer ${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;
}
);
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 {
// message.error(`${response.status}${response.statusText}`);
}
}
if (response.status >= 500) {
// notification.error({
// message: '网络出现了错误',
// description: response.statusText,
// });
}
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 !== '')
);
// 响应拦截器
instance.interceptors.response.use(
(response) => {
const apiResponse = response.data as ApiResponse;
// 检查业务状态
if (apiResponse.status === API_STATUS.OK) {
// 成功返回content内容
return apiResponse.content;
} 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);
}
},
(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;
}
// 创建不显示错误提示的HTTP客户端用于静默请求
export function createSilentHttpClient(serviceName: string) {
return createHttpClient({
serviceName,
showErrorToast: false,
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;
}