增加用户中心、用户注册登录

This commit is contained in:
jiangh277
2025-12-26 15:12:49 +08:00
parent 1eb1dafe1e
commit 07e011febd
43 changed files with 2006 additions and 611 deletions

View File

@@ -0,0 +1,68 @@
// src/components/Hooks/useAuthImageUrls.ts
import { useEffect, useState } from 'react';
import { fetchImage } from '@/services/file/api';
import { get } from 'lodash';
import { getAuthization } from '@/utils/userUtils';
const useAuthImageUrls = (imageIds: string[]) => {
const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
useEffect(() => {
const fetchImageUrls = async () => {
const newImageUrls: Record<string, string> = {};
const newLoading: Record<string, boolean> = { ...loading };
const promises = imageIds
.filter(id => !imageUrls[id] && !loading[id]) // 只获取尚未加载或正在加载的图片
.map(async (id) => {
if(!id) return;
newLoading[id] = true;
try {
const {data: response} = await fetchImage(id);
if (response) {
const objectUrl = URL.createObjectURL(response);
newImageUrls[id] = objectUrl;
}
} catch (error) {
console.error(`Failed to fetch image ${id}:`, error);
// 如果认证请求失败使用原始URL作为备选
newImageUrls[id] = `/file/image-low-res/${id}?Authorization=${getAuthization()}`;
} finally {
newLoading[id] = false;
}
});
if (promises.length > 0) {
setLoading({ ...newLoading });
await Promise.all(promises);
setImageUrls(prev => ({ ...prev, ...newImageUrls }));
setLoading(prev => {
const updatedLoading = { ...prev };
Object.keys(newImageUrls).forEach(key => {
delete updatedLoading[key];
});
return updatedLoading;
});
}
};
fetchImageUrls();
}, [imageIds, imageUrls, loading]);
// 组件卸载时清理Object URLs以避免内存泄漏
useEffect(() => {
return () => {
Object.values(imageUrls).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
};
}, [imageUrls]);
return { imageUrls, loading };
};
export default useAuthImageUrls;

View File

@@ -0,0 +1,56 @@
// src/components/Hooks/useFetchHighResImageUrl.ts
import { useRequest } from '@umijs/max';
import { useEffect, useRef, useState } from 'react';
import { fetchImage } from '@/services/file/api';
// 简单内存缓存,避免重复请求同一张图片
const highResImageUrlCache = new Map<string, string>();
const useFetchHighResImageUrl = (imageInstanceId: string) => {
const [imageUrl, setImageUrl] = useState<string | null>(null);
const retryRef = useRef(0);
const { run, loading } = useRequest(
() => fetchImage(imageInstanceId),
{
manual: true,
onSuccess: (data) => {
if (data) {
const objectUrl = URL.createObjectURL(data);
highResImageUrlCache.set(imageInstanceId, objectUrl);
setImageUrl(objectUrl);
} else {
setImageUrl(null);
}
retryRef.current = 0;
},
onError: () => {
if (retryRef.current < 2) {
retryRef.current += 1;
run();
} else {
setImageUrl(null);
highResImageUrlCache.set(imageInstanceId, "error");
retryRef.current = 0;
}
},
},
);
useEffect(() => {
if (imageInstanceId) {
retryRef.current = 0;
const cached = highResImageUrlCache.get(imageInstanceId);
if (cached && cached !== "error") {
setImageUrl(cached);
} else if (cached !== "error") {
run();
}
} else {
setImageUrl(null);
}
}, [imageInstanceId, run]);
return { imageUrl, loading };
};
export default useFetchHighResImageUrl;

View File

@@ -1,31 +1,54 @@
import { useRequest } from '@umijs/max';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {fetchImageLowRes} from "@/services/file/api";
// 简单内存缓存,避免因虚拟滚动重复请求同一张图片
const imageUrlCache = new Map<string, string>();
const useFetchImageUrl = (imageInstanceId: string) => {
const [imageUrl, setImageUrl] = useState("error");
const retryRef = useRef(0);
const { data: response, run, loading } = useRequest(
() => {
return fetchImageLowRes(imageInstanceId);
},
() => fetchImageLowRes(imageInstanceId),
{
manual: true,
onSuccess: (data) => {
if (data) {
const objectUrl = URL.createObjectURL(data);
imageUrlCache.set(imageInstanceId, objectUrl);
setImageUrl(objectUrl);
} else {
setImageUrl("error");
}
retryRef.current = 0;
},
onError: () => {
if (retryRef.current < 2) {
retryRef.current += 1;
run();
} else {
setImageUrl("error");
imageUrlCache.set(imageInstanceId, "error");
retryRef.current = 0;
}
},
},
);
useEffect(() => {
if (response) {
setImageUrl(URL.createObjectURL(response));
if (imageInstanceId) {
retryRef.current = 0;
const cached = imageUrlCache.get(imageInstanceId);
if (cached) {
setImageUrl(cached);
} else {
run();
}
} else {
setImageUrl("error");
}
}, [response]);
useEffect(() => {
if (imageInstanceId) {
run();
}
}, [imageInstanceId]);
}, [imageInstanceId, run]);
return {imageUrl, loading};
};
export default useFetchImageUrl;

View File

@@ -0,0 +1,216 @@
import { getAuthization } from '@/utils/userUtils';
import { useState, useEffect, useCallback, useRef } from 'react';
import SockJS from 'sockjs-client';
import { Client, IMessage } from '@stomp/stompjs';
interface WebSocketMessage {
type: 'chat' | 'system' | 'notification' | 'friend' | 'error';
content?: string;
sender?: string;
title?: string;
timestamp?: Date;
[key: string]: any; // 允许其他属性
}
interface WebSocketOptions {
headers?: Record<string, string>;
protocols?: string | string[];
}
const useWebSocket = (url: string, options?: WebSocketOptions) => {
const [messages, setMessages] = useState<WebSocketMessage[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);
const ws = useRef<WebSocket | null>(null);
const stompClient = useRef<Client | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
// 发送聊天消息
const sendChatMessage = useCallback((toUserId: string, content: string) => {
if (stompClient.current && stompClient.current.connected) {
stompClient.current.publish({
destination: '/app/chat/send',
body: JSON.stringify({
toUserId,
content,
timestamp: Date.now()
})
});
} else {
setError(new Error('WebSocket连接未建立'));
}
}, []);
// 发送通用消息
const sendMessage = useCallback((destination: string, body: any) => {
if (stompClient.current && stompClient.current.connected) {
stompClient.current.publish({
destination,
body: JSON.stringify(body)
});
} else {
setError(new Error('WebSocket连接未建立'));
}
}, []);
// 处理错误
const handleError = useCallback((error: Event) => {
setError(new Error(`WebSocket错误: ${(error as any).message || error.type}`));
}, []);
// 处理连接关闭
const handleClose = useCallback((event: CloseEvent) => {
setIsConnected(false);
console.log(`连接关闭: ${event.code} ${event.reason}`);
// 尝试重连
if (reconnectAttempts.current < maxReconnectAttempts) {
const delay = Math.pow(2, reconnectAttempts.current) * reconnectDelay;
setTimeout(() => {
reconnectAttempts.current++;
connect();
}, delay);
}
}, []);
// 连接WebSocket
const connect = useCallback(() => {
try {
// 构造带有查询参数的URL
const urlObj = new URL(url, window.location.href);
// 添加认证信息到查询参数
const token = getAuthization();
urlObj.searchParams.append('Authorization', token);
// 添加其他 headers 作为查询参数
if (options?.headers) {
Object.entries(options.headers).forEach(([key, value]) => {
urlObj.searchParams.append(key, value);
});
}
const finalUrl = urlObj.toString();
// 关闭之前的连接
if (ws.current) {
ws.current.close();
}
// 创建WebSocket连接
ws.current = new SockJS(finalUrl, options?.protocols as any);
// 创建STOMP客户端
stompClient.current = new Client({
webSocketFactory: () => ws.current as WebSocket,
connectHeaders: {
Authorization: getAuthization(),
},
debug: function (str) {
console.log('[STOMP]', str);
},
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
// 连接成功的回调
stompClient.current.onConnect = () => {
setIsConnected(true);
reconnectAttempts.current = 0;
console.log('WebSocket连接已建立');
// 订阅用户聊天消息
stompClient.current?.subscribe('/user/queue/chat', (message: IMessage) => {
try {
const chatMessage = JSON.parse(message.body);
setMessages(prev => [...prev, {
type: 'chat',
content: chatMessage.content,
senderId: chatMessage.fromUserId,
senderName: chatMessage.fromUserName,
status: chatMessage.status,
timestamp: new Date(chatMessage.timestamp || Date.now())
}]);
} catch (e) {
console.error('解析聊天消息失败:', e);
}
});
// 订阅好友通知
stompClient.current?.subscribe('/user/queue/friend', (message: IMessage) => {
try {
const friendMessage = JSON.parse(message.body);
console.log(message.body);
setMessages(prev => [...prev, {
type: 'friend',
content: `好友请求: ${friendMessage.type}`,
senderId: friendMessage.fromUserId,
senderName: friendMessage.fromUserName,
status: friendMessage.status,
timestamp: new Date()
}]);
} catch (e) {
console.error('解析好友消息失败:', e);
}
});
// 订阅系统通知
stompClient.current?.subscribe('/user/queue/notification', (message: IMessage) => {
try {
const notification = JSON.parse(message.body);
console.log('notification', message);
setMessages(prev => [...prev, {
type: 'notification',
title: notification.title,
content: notification.message || notification.content,
status: notification.status,
timestamp: new Date(notification.timestamp || Date.now())
}]);
} catch (e) {
console.error('解析通知消息失败:', e);
}
});
};
// STOMP错误处理
stompClient.current.onStompError = (frame) => {
console.error('Broker reported error: ' + frame.headers['message']);
console.error('Additional details: ' + frame.body);
setError(new Error(`STOMP错误: ${frame.headers['message']}`));
};
// 激活客户端
stompClient.current.activate();
} catch (e) {
setError(e as Error);
}
}, [url, options]);
// 初始化连接
useEffect(() => {
connect();
return () => {
if (stompClient.current) {
stompClient.current.deactivate();
}
if (ws.current) {
ws.current.close();
}
};
}, [connect]);
return {
messages,
sendChatMessage,
sendMessage,
isConnected,
error
};
};
export default useWebSocket;

View File

@@ -1,12 +1,13 @@
import { outLogin } from '@/services/ant-design-pro/api';
import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
import { history, useModel } from '@umijs/max';
import { Spin } from 'antd';
import { Spin, message } from 'antd';
import { createStyles } from 'antd-style';
import { stringify } from 'querystring';
import React, { useCallback } from 'react';
import { flushSync } from 'react-dom';
import HeaderDropdown from '../HeaderDropdown';
import { logoutUser } from '@/services/user/api';
export type GlobalHeaderRightProps = {
menu?: boolean;
@@ -16,7 +17,7 @@ export type GlobalHeaderRightProps = {
export const AvatarName = () => {
const { initialState } = useModel('@@initialState');
const { currentUser } = initialState || {};
return <span className="anticon">{currentUser?.name}</span>;
return <span className="anticon">{currentUser?.username}</span>;
};
const useStyles = createStyles(({ token }) => {
@@ -42,7 +43,13 @@ export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({ menu, childre
* 退出登录,并且将当前的 url 保存
*/
const loginOut = async () => {
await outLogin();
const response = await logoutUser();
console.log(response);
if (response.code !== 200) {
message.error(response.message);
return;
}
const { search, pathname } = window.location;
const urlParams = new URL(window.location.href).searchParams;
/** 此方法会跳转到 redirect 参数所在的位置 */
@@ -94,7 +101,7 @@ export const AvatarDropdown: React.FC<GlobalHeaderRightProps> = ({ menu, childre
const { currentUser } = initialState;
if (!currentUser || !currentUser.name) {
if (!currentUser || !currentUser.username) {
return loading;
}

View File

@@ -1,6 +1,8 @@
import useFetchImageUrl from '@/components/Hooks/useFetchImageUrl';
import useFetchHighResImageUrl from '@/components/Hooks/useFetchHighResImageUrl';
import { Image } from 'antd';
import React, { useState } from 'react';
import { getAuthization } from '@/utils/userUtils';
interface ImageItem {
instanceId: string;
@@ -32,28 +34,22 @@ const TimelineImage: React.FC<Props> = (props) => {
style,
} = props;
const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
const [previewVisible, setPreviewVisible] = useState(false);
/* const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
const [previewVisible, setPreviewVisible] = useState(false); */
// 构建预览列表
imageList.map((item) => ({
src: item.instanceId,
title: item.imageName,
}));
// 预览配置
const previewConfig = {
visible: previewVisible,
onVisibleChange: (visible: boolean) => setPreviewVisible(visible),
current: currentIndex,
};
return (
<div className="tl-image-container" style={{ width, height }}>
<Image
loading="lazy"
src={src ?? `/file/image-low-res/${imageInstanceId}`}
src={src ?? `/file/image-low-res/${imageInstanceId}?Authorization=${getAuthization()}`}
preview={{
src: `/file/image/${imageInstanceId}`,
src: `/file/image/${imageInstanceId}?Authorization=${getAuthization()}`,
}}
height={height}
width={width}
@@ -68,4 +64,4 @@ const TimelineImage: React.FC<Props> = (props) => {
);
};
export default TimelineImage;
export default TimelineImage;

View File

@@ -10,3 +10,9 @@ import { Question, SelectLang } from './RightContent';
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };
// 导出Hooks
export { default as useFetchImageUrl } from './Hooks/useFetchImageUrl';
export { default as useWebSocket } from './Hooks/useWebSocket';
export { default as useFetchHighResImageUrl } from './Hooks/useFetchHighResImageUrl';
export { default as useAuthImageUrls } from './Hooks/useAuthImageUrls';