增加用户中心、用户注册登录
This commit is contained in:
27
src/app.tsx
27
src/app.tsx
@@ -15,10 +15,19 @@ const loginPath = '/user/login';
|
||||
* */
|
||||
export async function getInitialState(): Promise<{
|
||||
settings?: Partial<LayoutSettings>;
|
||||
currentUser?: API.CurrentUser;
|
||||
currentUser?: UserInfo;
|
||||
loading?: boolean;
|
||||
fetchUserInfo?: () => Promise<API.CurrentUser | undefined>;
|
||||
logoutUser?: () => void
|
||||
}> {
|
||||
const readCachedUser = (): UserInfo | undefined => {
|
||||
const cached = localStorage.getItem('timeline_user');
|
||||
if (!cached) return undefined;
|
||||
try {
|
||||
return JSON.parse(cached) as UserInfo;;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const msg = await queryCurrentUser({
|
||||
@@ -30,18 +39,22 @@ export async function getInitialState(): Promise<{
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const logoutUser = () => {
|
||||
localStorage.removeItem("timeline_user");
|
||||
}
|
||||
// 如果不是登录页面,执行
|
||||
const { location } = history;
|
||||
const cachedUser = readCachedUser();
|
||||
if (![loginPath, '/user/register', '/user/register-result'].includes(location.pathname)) {
|
||||
const currentUser = await fetchUserInfo();
|
||||
const currentUser = cachedUser;
|
||||
return {
|
||||
fetchUserInfo,
|
||||
currentUser,
|
||||
settings: defaultSettings as Partial<LayoutSettings>,
|
||||
};
|
||||
}
|
||||
return {
|
||||
fetchUserInfo,
|
||||
currentUser: cachedUser,
|
||||
logoutUser,
|
||||
settings: defaultSettings as Partial<LayoutSettings>,
|
||||
};
|
||||
}
|
||||
@@ -50,13 +63,13 @@ export async function getInitialState(): Promise<{
|
||||
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
|
||||
return {
|
||||
actionsRender: () => [<Question key="doc" />, <SelectLang key="SelectLang" />],
|
||||
/*avatarProps: {
|
||||
avatarProps: {
|
||||
src: initialState?.currentUser?.avatar,
|
||||
title: <AvatarName />,
|
||||
render: (_, avatarChildren) => {
|
||||
return <AvatarDropdown>{avatarChildren}</AvatarDropdown>;
|
||||
},
|
||||
},*/
|
||||
},
|
||||
/*waterMarkProps: {
|
||||
content: initialState?.currentUser?.name,
|
||||
},*/
|
||||
|
||||
68
src/components/Hooks/useAuthImageUrls.ts
Normal file
68
src/components/Hooks/useAuthImageUrls.ts
Normal 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;
|
||||
56
src/components/Hooks/useFetchHighResImageUrl.ts
Normal file
56
src/components/Hooks/useFetchHighResImageUrl.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
216
src/components/Hooks/useWebSocket.ts
Normal file
216
src/components/Hooks/useWebSocket.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
'pages.layouts.userLayout.title': 'Ant Design 是西湖区最具影响力的 Web 设计规范',
|
||||
'pages.layouts.userLayout.title': '时光留痕,点滴回忆串联成线',
|
||||
'pages.login.accountLogin.tab': '账户密码登录',
|
||||
'pages.login.accountLogin.errorMessage': '错误的用户名和密码(admin/ant.design)',
|
||||
'pages.login.failure': '登录失败,请重试!',
|
||||
|
||||
107
src/pages/account/center/components/Dynamic/index.tsx
Normal file
107
src/pages/account/center/components/Dynamic/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useRequest } from '@umijs/max';
|
||||
import { List, Tag } from 'antd';
|
||||
import type { FC } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { queryFriendDynamic } from '../../service';
|
||||
import { history } from '@umijs/max';
|
||||
|
||||
type ActivityItem = {
|
||||
id: number;
|
||||
actorId: string;
|
||||
actorName: string;
|
||||
action: string;
|
||||
storyInstanceId: string;
|
||||
storyInstanceName: string;
|
||||
storyItemId: string;
|
||||
storyItemName: string;
|
||||
remark: string;
|
||||
activityTime: number[];
|
||||
itemTitle: string;
|
||||
itemDescription: string;
|
||||
itemLocation: string;
|
||||
itemTime: number[];
|
||||
};
|
||||
|
||||
const formatTime = (parts?: number[]) => {
|
||||
if (!parts || parts.length < 3) return '';
|
||||
const [y, m, d, h = 0, mi = 0, s = 0] = parts;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${y}-${pad(m)}-${pad(d)} ${pad(h)}:${pad(mi)}:${pad(s)}`;
|
||||
};
|
||||
|
||||
const formatAction = (action: string) => {
|
||||
switch (action) {
|
||||
case 'create_story_item':
|
||||
return '创建故事节点';
|
||||
case 'update_story_item':
|
||||
return '更新故事节点';
|
||||
case 'delete_story_item':
|
||||
return '删除故事节点';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
||||
// 动态 Tab:展示当前用户有权限看到的故事条目动态
|
||||
const Dynamic: FC = () => {
|
||||
const { data, loading } = useRequest(() => queryFriendDynamic());
|
||||
|
||||
const items: ActivityItem[] = useMemo(() => {
|
||||
return ((data as any)?.data || data || []) as ActivityItem[];
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<List<ActivityItem>
|
||||
size="large"
|
||||
itemLayout="vertical"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
rowKey={(item) => String(item.id)}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span>
|
||||
{/* 增加点击事件,只允许点击故事实例详情 */}
|
||||
<a onClick={() => {
|
||||
history.push(`/timeline/${item.storyInstanceId}`);
|
||||
}} style={{ cursor: 'pointer' }} title="点击查看故事实例详情" >
|
||||
<span>
|
||||
{item.actorName} 在{item.storyInstanceName}中<Tag color="blue">{`${formatAction(item.action)}`}</Tag>
|
||||
<span style={{ marginLeft: 8 }}>{item.storyItemName}</span>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span>
|
||||
<Tag color="green">发生时间</Tag>
|
||||
<span style={{ marginRight: 16 }}>{formatTime(item.itemTime)}</span>
|
||||
<Tag color="purple">记录时间</Tag>
|
||||
<span style={{ marginRight: 16 }}>{formatTime(item.activityTime)}</span>
|
||||
{item.itemLocation && (
|
||||
<span>
|
||||
<Tag color="gold">地点</Tag>
|
||||
<span>{item.itemLocation}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<strong>描述:</strong>
|
||||
<span>{item.itemDescription}</span>
|
||||
</div>
|
||||
{item.remark && (
|
||||
<div style={{ marginTop: 4, color: '#888' }}>
|
||||
<strong>备注:</strong>
|
||||
<span>{item.remark}</span>
|
||||
</div>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dynamic;
|
||||
151
src/pages/account/center/components/Friends/index.tsx
Normal file
151
src/pages/account/center/components/Friends/index.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
|
||||
import { UserAddOutlined, UserDeleteOutlined } from '@ant-design/icons';
|
||||
import { useRequest } from '@umijs/max';
|
||||
import { Avatar, Input, List, Button, Space, Popconfirm, message, Modal, Tooltip } from 'antd';
|
||||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { addFriend, queryCurrent, queryFriendList, searchUsername } from '../../service';
|
||||
import type { FriendUser, UserInfo } from '../../data';
|
||||
type Friend = { id: string; name: string; avatar?: string; remark?: string };
|
||||
const ListContent = ({
|
||||
data: { nickname, remark, createFriendTime, friendStatus },
|
||||
}: {
|
||||
data: FriendUser;
|
||||
}) => {
|
||||
return (
|
||||
<div >
|
||||
<div>
|
||||
<span>昵称:{nickname}</span>
|
||||
<span>备注:{remark}</span>
|
||||
<span>创建时间:{createFriendTime}</span>
|
||||
<span>好友状态:{friendStatus}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// 好友 Tab:展示并管理好友(前端状态占位实现)
|
||||
const Friends: FC = () => {
|
||||
|
||||
const [searchUsers, setSearchUsers] = useState<UserInfo[]>([]);
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [friends, setFriends] = useState<FriendUser[]>([]);
|
||||
|
||||
|
||||
const {run} = useRequest(() => queryFriendList(), {
|
||||
onSuccess: (data) => {
|
||||
setFriends(data ?? []);
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error.message);
|
||||
}
|
||||
})
|
||||
|
||||
const handleSearch = async (v: string) => {
|
||||
console.log(v);
|
||||
const res = await searchUsername({username: v});
|
||||
setSearchUsers(res.data);
|
||||
|
||||
}
|
||||
|
||||
const handleSelectFriend = async (candidate: UserInfo) => {
|
||||
if (friends?.some((f) => f.userId === candidate.userId)) {
|
||||
message.info('该好友已存在');
|
||||
return;
|
||||
}
|
||||
console.log(candidate);
|
||||
const res = await addFriend(candidate.userId);
|
||||
console.log(res);
|
||||
if (res.code === 200) {
|
||||
message.success(res.message);
|
||||
run();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
|
||||
message.success('已移除好友(本地状态)');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UserAddOutlined />}
|
||||
onClick={() => {
|
||||
setSearchKeyword('');
|
||||
setAddModalVisible(true);
|
||||
}}
|
||||
>
|
||||
添加好友
|
||||
</Button>
|
||||
</Space>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={friends}
|
||||
locale={{ emptyText: '暂无好友' }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="remove"
|
||||
title="确定删除该好友?"
|
||||
onConfirm={() => handleRemove(item.userId)}
|
||||
>
|
||||
<Button type="link" icon={<UserDeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={item.avatar}>{item.username}</Avatar>}
|
||||
title={item.username}
|
||||
description={item.description}
|
||||
/>
|
||||
<ListContent data={item} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="添加好友"
|
||||
open={addModalVisible}
|
||||
onCancel={() => setAddModalVisible(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input.Search
|
||||
placeholder="搜索好友名称"
|
||||
allowClear
|
||||
onSearch={handleSearch}
|
||||
onChange={(e) => setSearchKeyword(e.target.value.trim())}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<List
|
||||
locale={{ emptyText: '未找到匹配的用户' }}
|
||||
dataSource={searchUsers}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button key="add" type="link" onClick={() => handleSelectFriend(item)}>
|
||||
添加
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={item.avatar}>{item.username}</Avatar>}
|
||||
title={item.username}
|
||||
description={item.description}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Friends;
|
||||
191
src/pages/account/center/components/Message/index.tsx
Normal file
191
src/pages/account/center/components/Message/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { MessageOutlined } from '@ant-design/icons';
|
||||
import { Tag, List, Button, Space, message } from 'antd';
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { acceptFriendRequest, queryHistoryMessages, rejectFriendRequest } from '../../service';
|
||||
import type { HistoryMessage, MessageItem, WebSocketMessage } from '../../data';
|
||||
import { useRequest } from '@umijs/max';
|
||||
|
||||
|
||||
type MessageParams = {
|
||||
messages: WebSocketMessage[];
|
||||
sendChatMessage: (toUserId: string, content: string) => void;
|
||||
}
|
||||
|
||||
// 消息 Tab:基于 WebSocket 的好友请求 & 聊天消息展示
|
||||
const Messages: FC<MessageParams> = (props) => {
|
||||
const { messages: wsMessages, sendChatMessage } = props;
|
||||
const [localMessages, setLocalMessages] = useState<MessageItem[]>([]);
|
||||
|
||||
const { data: historyResp } = useRequest(() => queryHistoryMessages());
|
||||
|
||||
const historyMessages: MessageItem[] = useMemo(() => {
|
||||
const list = ((historyResp) || []) as HistoryMessage[];
|
||||
return list.map((m) => {
|
||||
const isFriendRequest = m.category === 'friend' && m.type === 'friend_request';
|
||||
const base = {
|
||||
id: String(m.id),
|
||||
category: m.category,
|
||||
senderId: m.fromUserId,
|
||||
senderName: m.title || m.fromUserId,
|
||||
content:
|
||||
m.content ||
|
||||
(m.type === 'friend_accept'
|
||||
? '你的好友请求已被接受'
|
||||
: m.type === 'friend_reject'
|
||||
? '你的好友请求已被拒绝'
|
||||
: ''),
|
||||
toUserId: m.toUserId,
|
||||
};
|
||||
|
||||
if (isFriendRequest) {
|
||||
return {
|
||||
...base,
|
||||
type: 'friend_request' as const,
|
||||
status: m.status === 'unread' ? 'pending' : 'accepted',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: 'friend_message' as const,
|
||||
status: 'read',
|
||||
};
|
||||
});
|
||||
}, [historyResp]);
|
||||
console.log(historyMessages);
|
||||
|
||||
const wsMapped: MessageItem[] = useMemo(() => {
|
||||
return wsMessages
|
||||
.map((m, index) => {
|
||||
if (m.type === 'friend') {
|
||||
const isRequest = m.subType === 'friend_request';
|
||||
const base = {
|
||||
id: `ws-friend-${index}`,
|
||||
category: 'friend',
|
||||
senderId: m.fromUserId || m.senderId || '',
|
||||
senderName: m.senderName || m.title || '好友',
|
||||
content:
|
||||
m.content ||
|
||||
(m.subType === 'friend_accept'
|
||||
? '你的好友请求已被接受'
|
||||
: m.subType === 'friend_reject'
|
||||
? '你的好友请求已被拒绝'
|
||||
: ''),
|
||||
toUserId: m.toUserId || '',
|
||||
};
|
||||
if (isRequest) {
|
||||
return {
|
||||
...base,
|
||||
type: 'friend_request' as const,
|
||||
status: 'pending' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'friend_message' as const,
|
||||
status: 'read' as const,
|
||||
};
|
||||
}
|
||||
if (m.type === 'chat') {
|
||||
return {
|
||||
id: `ws-chat-${index}`,
|
||||
type: 'friend_message' as const,
|
||||
category: 'chat',
|
||||
senderId: m.senderId || m.fromUserId || '',
|
||||
senderName: m.senderName || '好友',
|
||||
content: m.content || '',
|
||||
status: 'read' as const,
|
||||
toUserId: m.toUserId || '',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as MessageItem[];
|
||||
}, [wsMessages]);
|
||||
|
||||
const allMessages: MessageItem[] = useMemo(
|
||||
() => [...historyMessages, ...wsMapped, ...localMessages],
|
||||
[historyMessages, wsMapped, localMessages],
|
||||
);
|
||||
|
||||
const updateStatus = (id: string, status: Exclude<MessageItem['status'], undefined>) => {
|
||||
setLocalMessages((prev) =>
|
||||
prev.map((m) => (m.id === id ? { ...m, status } : m)) as MessageItem[],
|
||||
);
|
||||
};
|
||||
|
||||
const handleReply = (msg: Extract<MessageItem, { type: 'friend_message' }>) => {
|
||||
const reply = window.prompt(`回复 ${msg.senderName}:`, '');
|
||||
console.log(msg);
|
||||
|
||||
if (!reply) return;
|
||||
if (!msg.senderId) {
|
||||
message.error('缺少对方用户ID,无法发送消息');
|
||||
return;
|
||||
}
|
||||
sendChatMessage(msg.senderId, reply);
|
||||
message.success('已发送回复');
|
||||
};
|
||||
|
||||
const handleAccept = async (msg: Extract<MessageItem, { type: 'friend_request' }>) => {
|
||||
try {
|
||||
await acceptFriendRequest(msg.senderId);
|
||||
message.success('已接受好友请求');
|
||||
updateStatus(msg.id, 'accepted');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '接受失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (msg: Extract<MessageItem, { type: 'friend_request' }>) => {
|
||||
try {
|
||||
await rejectFriendRequest(msg.senderId);
|
||||
message.info('已拒绝好友请求');
|
||||
updateStatus(msg.id, 'rejected');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '拒绝失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={allMessages}
|
||||
locale={{ emptyText: '暂无消息' }}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
actions={[
|
||||
item.type === 'friend_request' && item.status === 'pending' ? (
|
||||
<Space key="actions">
|
||||
<Button size="small" type="primary" onClick={() => handleAccept(item)}>
|
||||
接受
|
||||
</Button>
|
||||
<Button size="small" danger onClick={() => handleReject(item)}>
|
||||
拒绝
|
||||
</Button>
|
||||
</Space>
|
||||
) : item.type === 'friend_message' ? (
|
||||
<Button
|
||||
key="reply"
|
||||
size="small"
|
||||
icon={<MessageOutlined />}
|
||||
onClick={() => handleReply(item)}
|
||||
>
|
||||
回复
|
||||
</Button>
|
||||
) : null,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={`${item.senderName} ${item.type === 'friend_request' ? '的好友请求' : '的消息'}`}
|
||||
description={item.content}
|
||||
/>
|
||||
{item.status && <Tag color="blue">状态:{item.status}</Tag>}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default Messages;
|
||||
69
src/pages/account/center/data.d.ts
vendored
69
src/pages/account/center/data.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
export type tabKeyType = 'articles' | 'applications' | 'projects';
|
||||
export type tabKeyType = 'dynamic' | 'friends' | 'messages';
|
||||
export interface TagType {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -27,6 +27,9 @@ export type NoticeType = {
|
||||
};
|
||||
|
||||
export type CurrentUser = {
|
||||
nickname: ReactNode;
|
||||
description: ReactNode;
|
||||
location: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
userid: string;
|
||||
@@ -35,7 +38,7 @@ export type CurrentUser = {
|
||||
signature: string;
|
||||
title: string;
|
||||
group: string;
|
||||
tags: TagType[];
|
||||
tags: string;
|
||||
notifyCount: number;
|
||||
unreadCount: number;
|
||||
country: string;
|
||||
@@ -43,6 +46,23 @@ export type CurrentUser = {
|
||||
address: string;
|
||||
phone: string;
|
||||
};
|
||||
export type UserInfo = {
|
||||
nickname: string;
|
||||
description: string;
|
||||
location: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
userId: string,
|
||||
email: string;
|
||||
tags: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
}
|
||||
export interface FriendUser extends UserInfo {
|
||||
createFriendTime: string;
|
||||
remark: string;
|
||||
friendStatus: string;
|
||||
}
|
||||
|
||||
export type Member = {
|
||||
avatar: string;
|
||||
@@ -73,3 +93,48 @@ export type ListItemDataType = {
|
||||
content: string;
|
||||
members: Member[];
|
||||
};
|
||||
|
||||
// 后端历史消息原始结构
|
||||
export interface HistoryMessage {
|
||||
id: number;
|
||||
category: string; // 如 friend
|
||||
type: string; // 如 friend_accept, friend_request, chat 等
|
||||
fromUserId: string;
|
||||
toUserId: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
timestamp: number;
|
||||
status: string; // 如 unread, read
|
||||
}
|
||||
|
||||
// 前端用于展示的消息结构
|
||||
export type MessageItem =
|
||||
| {
|
||||
id: string;
|
||||
type: 'friend_request';
|
||||
category: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
status?: 'pending' | 'accepted' | 'rejected';
|
||||
toUserId: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'friend_message';
|
||||
category: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
status?: 'read';
|
||||
toUserId: string;
|
||||
};
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: 'chat' | 'system' | 'notification' | 'friend' | 'error';
|
||||
content?: string;
|
||||
sender?: string;
|
||||
title?: string;
|
||||
timestamp?: Date;
|
||||
[key: string]: any; // 允许其他属性
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
import { ClusterOutlined, ContactsOutlined, HomeOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { ClusterOutlined, ContactsOutlined, HomeOutlined, PlusOutlined, UserAddOutlined, UserDeleteOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { GridContent } from '@ant-design/pro-components';
|
||||
import { useRequest } from '@umijs/max';
|
||||
import { Avatar, Card, Col, Divider, Input, InputRef, Row, Tag } from 'antd';
|
||||
import { Avatar, Card, Col, Divider, Input, InputRef, Row, Tag, List, Button, Space, Popconfirm, message } from 'antd';
|
||||
import type { FC } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import useStyles from './Center.style';
|
||||
import Applications from './components/Applications';
|
||||
import Articles from './components/Articles';
|
||||
import Projects from './components/Projects';
|
||||
import type { CurrentUser, tabKeyType, TagType } from './data.d';
|
||||
import { queryCurrent } from './service';
|
||||
import Dynamic from './components/Dynamic';
|
||||
import Friends from './components/Friends';
|
||||
import Messages from './components/Message';
|
||||
import useWebSocket from '@/components/Hooks/useWebSocket';
|
||||
const operationTabList = [
|
||||
{
|
||||
key: 'articles',
|
||||
key: 'dynamic',
|
||||
tab: (
|
||||
<span>
|
||||
文章{' '}
|
||||
动态{' '}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
@@ -26,10 +28,10 @@ const operationTabList = [
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'applications',
|
||||
key: 'friends',
|
||||
tab: (
|
||||
<span>
|
||||
应用{' '}
|
||||
好友{' '}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
@@ -41,10 +43,10 @@ const operationTabList = [
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
key: 'messages',
|
||||
tab: (
|
||||
<span>
|
||||
项目{' '}
|
||||
消息{' '}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
@@ -56,8 +58,8 @@ const operationTabList = [
|
||||
),
|
||||
},
|
||||
];
|
||||
const TagList: React.FC<{
|
||||
tags: CurrentUser['tags'];
|
||||
const TagList: FC<{
|
||||
tags: TagType[];
|
||||
}> = ({ tags }) => {
|
||||
const { styles } = useStyles();
|
||||
const ref = useRef<InputRef | null>(null);
|
||||
@@ -122,20 +124,22 @@ const TagList: React.FC<{
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const Center: React.FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const [tabKey, setTabKey] = useState<tabKeyType>('articles');
|
||||
|
||||
const [tabKey, setTabKey] = useState<tabKeyType>('dynamic');
|
||||
const { messages: wsMessages, sendChatMessage } = useWebSocket('/user-api/ws');
|
||||
// 获取用户信息
|
||||
const { data: currentUser, loading } = useRequest(() => {
|
||||
return queryCurrent();
|
||||
});
|
||||
|
||||
// 渲染用户信息
|
||||
const renderUserInfo = ({ title, group, geographic }: Partial<CurrentUser>) => {
|
||||
const renderUserInfo = ({ title, group, location }: Partial<CurrentUser>) => {
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<p>
|
||||
{/* <p>
|
||||
<ContactsOutlined
|
||||
style={{
|
||||
marginRight: 8,
|
||||
@@ -150,7 +154,7 @@ const Center: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
{group}
|
||||
</p>
|
||||
</p> */}
|
||||
<p>
|
||||
<HomeOutlined
|
||||
style={{
|
||||
@@ -158,22 +162,7 @@ const Center: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
{
|
||||
(
|
||||
geographic || {
|
||||
province: {
|
||||
label: '',
|
||||
},
|
||||
}
|
||||
).province.label
|
||||
}
|
||||
{
|
||||
(
|
||||
geographic || {
|
||||
city: {
|
||||
label: '',
|
||||
},
|
||||
}
|
||||
).city.label
|
||||
location || ""
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -182,14 +171,14 @@ const Center: React.FC = () => {
|
||||
|
||||
// 渲染tab切换
|
||||
const renderChildrenByTabKey = (tabValue: tabKeyType) => {
|
||||
if (tabValue === 'projects') {
|
||||
return <Projects />;
|
||||
if (tabValue === 'dynamic') {
|
||||
return <Dynamic />;
|
||||
}
|
||||
if (tabValue === 'applications') {
|
||||
return <Applications />;
|
||||
if (tabValue === 'friends') {
|
||||
return <Friends />;
|
||||
}
|
||||
if (tabValue === 'articles') {
|
||||
return <Articles />;
|
||||
if (tabValue === 'messages') {
|
||||
return <Messages messages={wsMessages} sendChatMessage={sendChatMessage} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -208,12 +197,12 @@ const Center: React.FC = () => {
|
||||
<div>
|
||||
<div className={styles.avatarHolder}>
|
||||
<img alt="" src={currentUser.avatar} />
|
||||
<div className={styles.name}>{currentUser.name}</div>
|
||||
<div>{currentUser?.signature}</div>
|
||||
<div className={styles.name}>{currentUser.nickname}</div>
|
||||
<div>{currentUser?.description}</div>
|
||||
</div>
|
||||
{renderUserInfo(currentUser)}
|
||||
<Divider dashed />
|
||||
<TagList tags={currentUser.tags || []} />
|
||||
<TagList tags={currentUser.tags?.split(',')?.map((item) => ({ key: item, label: item })) as TagType[] || []} />
|
||||
<Divider
|
||||
style={{
|
||||
marginTop: 16,
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import { request } from '@umijs/max';
|
||||
import type { CurrentUser, ListItemDataType } from './data.d';
|
||||
import type { CurrentUser, FriendUser, HistoryMessage, ListItemDataType, MessageItem, UserInfo } from './data.d';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
|
||||
export async function queryCurrent(): Promise<{ data: CurrentUser }> {
|
||||
return request('/api/currentUserDetail');
|
||||
return request('/user-api/info');
|
||||
}
|
||||
export async function searchUsername(params: {username: string}) : Promise<{data: UserInfo[]}> {
|
||||
return request('/user-api/search', {
|
||||
params: params,
|
||||
method: "GET"
|
||||
})
|
||||
}
|
||||
|
||||
export async function addFriend(userId: string) : Promise<CommonResponse<String>> {
|
||||
return request('/user-api/friend/request', {
|
||||
data: {
|
||||
friendId: userId,
|
||||
},
|
||||
method: "POST"
|
||||
})
|
||||
}
|
||||
export async function queryFriendList(): Promise<CommonResponse<FriendUser[]>> {
|
||||
return request('/user-api/friend/list');
|
||||
}
|
||||
|
||||
export async function acceptFriendRequest(friendId: string): Promise<CommonResponse<string>> {
|
||||
return request('/user-api/friend/accept', {
|
||||
method: 'POST',
|
||||
data: { friendId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectFriendRequest(friendId: string): Promise<CommonResponse<string>> {
|
||||
return request('/user-api/friend/reject', {
|
||||
method: 'POST',
|
||||
data: { friendId },
|
||||
});
|
||||
}
|
||||
export async function queryHistoryMessages(): Promise<CommonResponse<HistoryMessage[]>> {
|
||||
return request('/user-api/message/history/friend', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
export async function queryFriendDynamic(): Promise<CommonResponse<HistoryMessage[]>> {
|
||||
return request('/story/activity/authorized-items', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
export async function queryFakeList(params: {
|
||||
count: number;
|
||||
}): Promise<{ data: { list: ListItemDataType[] } }> {
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
ProFormTextArea,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useRequest } from '@umijs/max';
|
||||
import { Button, Input, message, Upload } from 'antd';
|
||||
import { Button, Cascader, Input, message, Upload } from 'antd';
|
||||
import React from 'react';
|
||||
import { queryCity, queryCurrent, queryProvince } from '../service';
|
||||
import { queryCity, queryCurrent, queryProvince, updateCurrentUser } from '../service';
|
||||
import useStyles from './index.style';
|
||||
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
||||
|
||||
const validatorPhone = (rule: any, value: string[], callback: (message?: string) => void) => {
|
||||
if (!value[0]) {
|
||||
@@ -55,7 +56,13 @@ const BaseView: React.FC = () => {
|
||||
}
|
||||
return '';
|
||||
};
|
||||
const handleFinish = async () => {
|
||||
const handleFinish = async (values: any) => {
|
||||
console.log(values);
|
||||
values.location = values.location ? code2Location(values.location) : '';
|
||||
values.phone = Array.isArray(values.phone) ? values.phone[0] : values.phone;
|
||||
const res = await updateCurrentUser(values);
|
||||
console.log(res);
|
||||
|
||||
message.success('更新基本信息成功');
|
||||
};
|
||||
return (
|
||||
@@ -74,52 +81,65 @@ const BaseView: React.FC = () => {
|
||||
}}
|
||||
initialValues={{
|
||||
...currentUser,
|
||||
phone: currentUser?.phone.split('-'),
|
||||
phone: currentUser?.phone?.split('-'),
|
||||
}}
|
||||
hideRequiredMark
|
||||
>
|
||||
<ProFormText
|
||||
width="md"
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入您的邮箱!',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText
|
||||
width="md"
|
||||
name="name"
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
rules={[
|
||||
{
|
||||
/* {
|
||||
required: true,
|
||||
message: '请输入您的昵称!',
|
||||
},
|
||||
}, */
|
||||
]}
|
||||
/>
|
||||
<ProFormTextArea
|
||||
name="profile"
|
||||
name="description"
|
||||
label="个人简介"
|
||||
rules={[
|
||||
{
|
||||
/* {
|
||||
required: true,
|
||||
message: '请输入个人简介!',
|
||||
},
|
||||
}, */
|
||||
]}
|
||||
placeholder="个人简介"
|
||||
/>
|
||||
<ProFormText
|
||||
width="md"
|
||||
name="email"
|
||||
label="邮箱"
|
||||
rules={[
|
||||
/* {
|
||||
required: true,
|
||||
message: '请输入您的邮箱!',
|
||||
}, */
|
||||
]}
|
||||
/>
|
||||
<ProFormText
|
||||
name="phone"
|
||||
label="联系电话"
|
||||
rules={[
|
||||
/* {
|
||||
required: true,
|
||||
message: '请输入您的联系电话!',
|
||||
}, */
|
||||
/* {
|
||||
validator: validatorPhone,
|
||||
}, */
|
||||
]}
|
||||
/>
|
||||
<ProFormSelect
|
||||
width="sm"
|
||||
name="country"
|
||||
label="国家/地区"
|
||||
rules={[
|
||||
{
|
||||
/* {
|
||||
required: true,
|
||||
message: '请输入您的国家或地区!',
|
||||
},
|
||||
}, */
|
||||
]}
|
||||
options={[
|
||||
{
|
||||
@@ -128,94 +148,14 @@ const BaseView: React.FC = () => {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProForm.Group title="所在省市" size={8}>
|
||||
<ProFormSelect
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入您的所在省!',
|
||||
},
|
||||
]}
|
||||
width="sm"
|
||||
fieldProps={{
|
||||
labelInValue: true,
|
||||
}}
|
||||
name="province"
|
||||
className={styles.item}
|
||||
request={async () => {
|
||||
return queryProvince().then(({ data }) => {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
};
|
||||
});
|
||||
});
|
||||
}}
|
||||
<ProFormText width="md" label="位置" name="location" rules={[{ required: false }]}>
|
||||
<Cascader
|
||||
options={chinaRegion}
|
||||
fieldNames={{ label: 'title', value: 'value', children: 'children' }}
|
||||
placeholder="请选择省/市/区"
|
||||
/>
|
||||
<ProFormDependency name={['province']}>
|
||||
{({ province }) => {
|
||||
return (
|
||||
<ProFormSelect
|
||||
params={{
|
||||
key: province?.value,
|
||||
}}
|
||||
name="city"
|
||||
width="sm"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入您的所在城市!',
|
||||
},
|
||||
]}
|
||||
disabled={!province}
|
||||
className={styles.item}
|
||||
request={async () => {
|
||||
if (!province?.key) {
|
||||
return [];
|
||||
}
|
||||
return queryCity(province.key || '').then(({ data }) => {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
};
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ProFormDependency>
|
||||
</ProForm.Group>
|
||||
<ProFormText
|
||||
width="md"
|
||||
name="address"
|
||||
label="街道地址"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入您的街道地址!',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormFieldSet
|
||||
name="phone"
|
||||
label="联系电话"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入您的联系电话!',
|
||||
},
|
||||
{
|
||||
validator: validatorPhone,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input className={styles.area_code} />
|
||||
<Input className={styles.phone_number} />
|
||||
</ProFormFieldSet>
|
||||
</ProFormText>
|
||||
|
||||
</ProForm>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { request } from '@umijs/max';
|
||||
import type { CurrentUser, GeographicItemType } from './data';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
|
||||
export async function queryCurrent(): Promise<{ data: CurrentUser }> {
|
||||
return request('/api/accountSettingCurrentUser');
|
||||
return request('/user-api/info');
|
||||
}
|
||||
export async function updateCurrentUser(params: CurrentUser): Promise<{data: CommonResponse<string>}> {
|
||||
return request('/user-api/info', {
|
||||
method: 'PUT',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
export async function queryProvince(): Promise<{ data: GeographicItemType[] }> {
|
||||
return request('/api/geographic/province');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ProColumns } from '@ant-design/pro-components';
|
||||
import { ProTable } from '@ant-design/pro-components';
|
||||
import { FC } from 'react';
|
||||
import '../index.css';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
|
||||
interface GalleryTableProps {
|
||||
imageList: ImageItem[];
|
||||
@@ -43,7 +44,7 @@ const GalleryTable: FC<GalleryTableProps> = ({
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
backgroundImage: `url(/file/image-low-res/${record.instanceId})`,
|
||||
backgroundImage: `url(/file/image-low-res/${record.instanceId}?Authorization=${getAuthization()})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// src/pages/gallery/components/GridView.tsx
|
||||
// src/pages/gallery\components\GridView.tsx
|
||||
import { ImageItem } from '@/pages/gallery/typings';
|
||||
import { DeleteOutlined, DownloadOutlined, EyeOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, DownloadOutlined, EyeOutlined, MoreOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd';
|
||||
import React, { FC, useCallback } from 'react';
|
||||
import React, { FC, useCallback, useState, useEffect } from 'react';
|
||||
import useAuthImageUrls from '@/components/Hooks/useAuthImageUrls';
|
||||
import { fetchImage } from '@/services/file/api';
|
||||
import '../index.css';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
|
||||
interface GridViewProps {
|
||||
imageList: ImageItem[];
|
||||
@@ -78,13 +81,13 @@ const GridView: FC<GridViewProps> = ({
|
||||
);
|
||||
|
||||
// 根据视图模式确定图像 URL
|
||||
const getImageUrl = (instanceId: string) => {
|
||||
// 小图模式使用低分辨率图像
|
||||
if (viewMode === 'small') {
|
||||
return `/file/image-low-res/${instanceId}`;
|
||||
const getImageUrl = (instanceId: string, isHighRes?: boolean) => {
|
||||
// 小图模式使用低分辨率图像,除非明确要求高清
|
||||
if (viewMode === 'small' && !isHighRes) {
|
||||
return `/file/image-low-res/${instanceId}?Authorization=${getAuthization()}`;
|
||||
}
|
||||
// 其他模式使用原图
|
||||
return `/file/image/${instanceId}`;
|
||||
return `/file/image/${instanceId}?Authorization=${getAuthization()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -106,10 +109,14 @@ const GridView: FC<GridViewProps> = ({
|
||||
style={{
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
backgroundImage: `url(${getImageUrl(item.instanceId)})`,
|
||||
backgroundImage: `url(${getImageUrl(item.instanceId, false)})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => !batchMode && onPreview(index)}
|
||||
/>
|
||||
@@ -132,4 +139,4 @@ const GridView: FC<GridViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default GridView;
|
||||
export default GridView;
|
||||
@@ -4,6 +4,7 @@ import { DeleteOutlined, DownloadOutlined, EyeOutlined } from '@ant-design/icons
|
||||
import { Button, Card, Checkbox, Spin } from 'antd';
|
||||
import React, { FC } from 'react';
|
||||
import '../index.css';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
|
||||
interface ListViewProps {
|
||||
imageList: ImageItem[];
|
||||
@@ -50,10 +51,11 @@ const ListView: FC<ListViewProps> = ({
|
||||
style={{
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
backgroundImage: `url(/file/image/${item.instanceId})`,
|
||||
backgroundImage: `url(/file/image/${item.instanceId}?Authorization=${getAuthization()})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => !batchMode && onPreview(index)}
|
||||
/>
|
||||
@@ -94,4 +96,4 @@ const ListView: FC<ListViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
export default ListView;
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/pages/gallery/index.tsx
|
||||
import { ImageItem } from '@/pages/gallery/typings';
|
||||
import { deleteImage, getImagesList, uploadImage } from '@/services/file/api';
|
||||
import { deleteImage, getImagesList, uploadImage, fetchImage } from '@/services/file/api';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { useRequest } from '@umijs/max';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
@@ -12,6 +12,7 @@ import GalleryToolbar from './components/GalleryToolbar';
|
||||
import GridView from './components/GridView';
|
||||
import ListView from './components/ListView';
|
||||
import './index.css';
|
||||
import { getAuthization } from '@/utils/userUtils';
|
||||
|
||||
const Gallery: FC = () => {
|
||||
const [viewMode, setViewMode] = useState<'small' | 'large' | 'list' | 'table'>('small');
|
||||
@@ -125,11 +126,29 @@ const Gallery: FC = () => {
|
||||
}, []);
|
||||
|
||||
// 下载图片
|
||||
const handleDownload = useCallback((instanceId: string, imageName?: string) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/file/image/${instanceId}`;
|
||||
link.download = imageName ?? 'default';
|
||||
link.click();
|
||||
const handleDownload = useCallback(async (instanceId: string, imageName?: string) => {
|
||||
try {
|
||||
// 使用项目中已有的fetchImage API,它会自动通过请求拦截器添加认证token
|
||||
const {data: response} = await fetchImage(instanceId);
|
||||
|
||||
if (response) {
|
||||
// 创建一个临时的URL用于下载
|
||||
const blob = response;
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 创建一个临时的下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = imageName ?? 'image';
|
||||
link.click();
|
||||
|
||||
// 清理临时URL
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
message.error('下载失败,请重试');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 上传图片
|
||||
@@ -249,10 +268,10 @@ const Gallery: FC = () => {
|
||||
|
||||
selectedRowKeys.forEach((id, index) => {
|
||||
// 添加延迟避免浏览器限制
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
const item = imageList.find((img) => img.instanceId === id);
|
||||
if (item) {
|
||||
handleDownload(id, item.imageName);
|
||||
await handleDownload(id, item.imageName);
|
||||
}
|
||||
}, index * 100);
|
||||
});
|
||||
@@ -384,7 +403,7 @@ const Gallery: FC = () => {
|
||||
renderView()
|
||||
)}
|
||||
|
||||
{/* 预览组件 - 根据视图模式决定使用哪种图像 */}
|
||||
{/* 预览组件 - 使用认证后的图像URL */}
|
||||
<Image.PreviewGroup
|
||||
preview={{
|
||||
visible: previewVisible,
|
||||
@@ -398,8 +417,8 @@ const Gallery: FC = () => {
|
||||
key={item.instanceId}
|
||||
src={
|
||||
viewMode === 'small'
|
||||
? `/file/image/${item.instanceId}`
|
||||
: `/file/image-low-res/${item.instanceId}`
|
||||
? `/file/image/${item.instanceId}?Authorization=${getAuthization()}`
|
||||
: `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`
|
||||
}
|
||||
style={{ display: 'none' }}
|
||||
alt={item.imageName}
|
||||
@@ -411,4 +430,4 @@ const Gallery: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Gallery;
|
||||
export default Gallery;
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
|
||||
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
||||
import { addStoryItem } from '@/pages/story/service';
|
||||
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
|
||||
import { getImagesList } from '@/services/file/api'; // 引入获取图库图片的API
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { useRequest } from '@umijs/max';
|
||||
@@ -102,7 +102,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
}
|
||||
}, [visible, activeTab, searchKeyword]);
|
||||
|
||||
const { run: submitItem, loading } = useRequest((newItem) => addStoryItem(newItem), {
|
||||
const { run: submitItem, loading } = useRequest((newItem) => option.includes('edit') ? updateStoryItem(newItem) : addStoryItem(newItem), {
|
||||
manual: true,
|
||||
onSuccess: (data) => {
|
||||
console.log(data);
|
||||
@@ -393,4 +393,4 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTimeLineItemModal;
|
||||
export default AddTimeLineItemModal;
|
||||
69
src/pages/story/components/AuthorizeStoryModal.tsx
Normal file
69
src/pages/story/components/AuthorizeStoryModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// 支持授权故事给其他用户,包含查看、新增、增删、管理员权限,查询当前好友列表,选择权限授权
|
||||
import { queryFriendList } from '@/pages/account/center/service';
|
||||
import { useRequest } from '@umijs/max';
|
||||
import { Form, message, Modal, Select } from 'antd';
|
||||
import { StoryType } from '../data';
|
||||
import { values } from 'lodash';
|
||||
import { authorizeStoryPermission } from '../service';
|
||||
|
||||
const AuthorizeStoryModal = ({
|
||||
open,
|
||||
current,
|
||||
handleOk,
|
||||
}: {
|
||||
open: boolean;
|
||||
current: Partial<StoryType> | undefined;
|
||||
handleOk: (flag: boolean) => void;
|
||||
}) => {
|
||||
const { data: friends, loading } = useRequest(() => queryFriendList());
|
||||
const [form] = Form.useForm();
|
||||
const onFinish = async (values: {userId: string, permissionType: number}) => {
|
||||
console.log(current, values);
|
||||
if (!values.userId || !values.permissionType || !current?.instanceId) return;
|
||||
let params = {userId: values.userId, permissionType: values.permissionType, storyInstanceId: current?.instanceId ?? '11'};
|
||||
const res = await authorizeStoryPermission(params);
|
||||
console.log(res);
|
||||
|
||||
if (res.code === 200) {
|
||||
handleOk(true);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal title="授权故事" open={open} onCancel={() => handleOk(false)} onOk={() => onFinish(form.getFieldsValue())}>
|
||||
{/* 请选择需要授权的好友 */}
|
||||
<Form onFinish={onFinish} form={form}>
|
||||
<Form.Item name={'userId'} label="授权对象">
|
||||
<Select
|
||||
// mode="multiple"
|
||||
>
|
||||
{friends?.map((item) => {
|
||||
return (
|
||||
<Select.Option title={item.username} key={item.userId}>
|
||||
{item.username}
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="permissionType" label="权限类型">
|
||||
<Select>
|
||||
<Select.Option key="2" title="查看">
|
||||
可编辑
|
||||
</Select.Option>
|
||||
<Select.Option key="3" title="增加">
|
||||
可增加
|
||||
</Select.Option>
|
||||
<Select.Option key="4" title="增删">
|
||||
可增删
|
||||
</Select.Option>
|
||||
<Select.Option key="5" title="管理">
|
||||
可管理
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorizeStoryModal;
|
||||
@@ -56,23 +56,29 @@ const OperationModal: FC<OperationModalProps> = (props) => {
|
||||
// 强制设置裁剪尺寸为 40x40
|
||||
canvas.width = 40;
|
||||
canvas.height = 40;
|
||||
ctx.drawImage(img, 0, 0, 40, 40);
|
||||
if (ctx) {
|
||||
ctx.drawImage(img, 0, 0, 40, 40);
|
||||
|
||||
// 生成 Base64 图像
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
setSelectedIcon(base64);
|
||||
setIconPreview(base64);
|
||||
setFileList([
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'icon.png',
|
||||
status: 'done',
|
||||
url: base64,
|
||||
originFileObj: new File([dataURLtoBlob(base64)], 'icon.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
// 生成 Base64 图像
|
||||
const base64 = canvas.toDataURL('image/png');
|
||||
setSelectedIcon(base64);
|
||||
setIconPreview(base64);
|
||||
setFileList([
|
||||
{
|
||||
uid: '-1',
|
||||
name: 'icon.png',
|
||||
status: 'done',
|
||||
url: base64,
|
||||
originFileObj: new File([dataURLtoBlob(base64)], 'icon.png', {
|
||||
type: 'image/png',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
message.error('获取 Canvas 绘图上下文失败');
|
||||
return;
|
||||
}
|
||||
|
||||
};
|
||||
img.onerror = () => {
|
||||
message.error('图像加载失败');
|
||||
@@ -90,7 +96,14 @@ const OperationModal: FC<OperationModalProps> = (props) => {
|
||||
// Base64 → Blob 转换工具函数
|
||||
const dataURLtoBlob = (dataurl: string) => {
|
||||
const arr = dataurl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)[1];
|
||||
if (!arr || arr.length < 2) {
|
||||
throw new Error('无效的Base64数据');
|
||||
}
|
||||
const mimeMatch = arr[0]?.match(/:(.*?);/);
|
||||
if (!mimeMatch || !mimeMatch[1]) {
|
||||
throw new Error('无法解析MIME类型');
|
||||
}
|
||||
const mime = mimeMatch[1];
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
@@ -248,7 +261,7 @@ const OperationModal: FC<OperationModalProps> = (props) => {
|
||||
},
|
||||
]);
|
||||
};
|
||||
img.src = iconPreview;
|
||||
img.src = iconPreview ?? '';
|
||||
}}
|
||||
>
|
||||
<Upload
|
||||
@@ -294,38 +307,10 @@ const OperationModal: FC<OperationModalProps> = (props) => {
|
||||
placeholder="请选择"
|
||||
/>
|
||||
|
||||
<ProFormSelect
|
||||
name="ownerId"
|
||||
label="故事负责人"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择故事负责人',
|
||||
},
|
||||
]}
|
||||
options={[
|
||||
{
|
||||
label: '付晓晓',
|
||||
value: 'xiao',
|
||||
},
|
||||
{
|
||||
label: '周毛毛',
|
||||
value: 'mao',
|
||||
},
|
||||
]}
|
||||
placeholder="请选择管理员"
|
||||
/>
|
||||
|
||||
<ProFormTextArea
|
||||
name="description"
|
||||
label="产品描述"
|
||||
rules={[
|
||||
{
|
||||
message: '请输入至少五个字符的产品描述!',
|
||||
min: 5,
|
||||
},
|
||||
]}
|
||||
placeholder="请输入至少五个字符"
|
||||
label="故事描述"
|
||||
placeholder="可以简单描述当前故事"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -3,12 +3,26 @@ import TimelineImage from '@/components/TimelineImage';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { DeleteOutlined, DownOutlined, EditOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import { useIntl, useRequest } from '@umijs/max';
|
||||
import { Button, Card, message, Popconfirm } from 'antd';
|
||||
import { Button, Card, message, Popconfirm, Tag, Space } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { queryStoryItemImages, removeStoryItem } from '../../service';
|
||||
import TimelineItemDrawer from '../TimelineItemDrawer';
|
||||
import useStyles from './index.style';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
if (!time) return '';
|
||||
|
||||
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
|
||||
if (Array.isArray(time)) {
|
||||
const [year, month, day, hour, minute, second] = time;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 如果已经是字符串格式,直接返回
|
||||
return String(time);
|
||||
};
|
||||
|
||||
const TimelineItem: React.FC<{
|
||||
item: StoryItem;
|
||||
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
|
||||
@@ -20,8 +34,11 @@ const TimelineItem: React.FC<{
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const [subItemsExpanded, setSubItemsExpanded] = useState(false);
|
||||
const [openDetail, setOpenDetail] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { data: imagesList } = useRequest(async () => {
|
||||
return await queryStoryItemImages(item.instanceId);
|
||||
}, {
|
||||
refreshDeps: [item.instanceId]
|
||||
});
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -52,10 +69,32 @@ const TimelineItem: React.FC<{
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.timelineItem}
|
||||
title={item.title}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
className={`${styles.timelineItem} ${hovered ? styles.timelineItemHover : ''}`}
|
||||
title={
|
||||
<div className={styles.timelineItemTitle}>
|
||||
<div className={styles.timelineItemTitleText}>{item.title}</div>
|
||||
<Space className={styles.timelineItemTags}>
|
||||
{item.createName && (
|
||||
<Tag color="blue" className={styles.creatorTag}>
|
||||
创建: {item.createName}
|
||||
</Tag>
|
||||
)}
|
||||
{item.updateName && item.updateName !== item.createName && (
|
||||
<Tag color="green" className={styles.updaterTag}>
|
||||
更新: {item.updateName}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
onMouseEnter={() => {
|
||||
setShowActions(true);
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setShowActions(false);
|
||||
setHovered(false);
|
||||
}}
|
||||
extra={
|
||||
<div className={styles.actions}>
|
||||
{showActions && (
|
||||
@@ -100,12 +139,14 @@ const TimelineItem: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
// onClick={() => onDetail(item)}
|
||||
hoverable
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.date} onClick={() => setOpenDetail(true)}>
|
||||
{item.storyItemTime} {item.location ? `创建于${item.location}` : ''}
|
||||
<Space size="small" className={styles.dateInfo}>
|
||||
<span className={styles.time}>{formatTimeArray(item.storyItemTime)}</span>
|
||||
{item.location && <span className={styles.location}>📍 {item.location}</span>}
|
||||
</Space>
|
||||
</div>
|
||||
<div className={styles.description} onClick={() => setOpenDetail(true)}>
|
||||
{displayedDescription}
|
||||
@@ -125,13 +166,13 @@ const TimelineItem: React.FC<{
|
||||
</div>
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '20px' }}>
|
||||
<div className={styles.timelineItemImages}>
|
||||
{imagesList.map((imageInstanceId, index) => (
|
||||
<TimelineImage
|
||||
key={imageInstanceId + index}
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
style={{ maxWidth: '100%', height: 'auto' }} // 添加响应式样式
|
||||
className={styles.timelineImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -156,7 +197,7 @@ const TimelineItem: React.FC<{
|
||||
{item.subItems.map((subItem) => (
|
||||
<div key={subItem.id} className={styles.subItem}>
|
||||
<div className={styles.subItemDate}>
|
||||
{item.storyItemTime} {item.location ? `创建于${item.location}` : ''}
|
||||
{formatTimeArray(item.storyItemTime)} {item.location ? `创建于${item.location}` : ''}
|
||||
</div>
|
||||
<div className={styles.subItemContent}>{subItem.description}</div>
|
||||
</div>
|
||||
@@ -177,4 +218,4 @@ const TimelineItem: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineItem;
|
||||
export default TimelineItem;
|
||||
@@ -9,9 +9,6 @@ const useStyles = createStyles(({ token }) => {
|
||||
borderRadius: token.borderRadius,
|
||||
transition: 'all 0.3s',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
},
|
||||
position: 'relative',
|
||||
padding: '20px',
|
||||
maxWidth: '100%',
|
||||
@@ -24,6 +21,9 @@ const useStyles = createStyles(({ token }) => {
|
||||
padding: '10px',
|
||||
},
|
||||
},
|
||||
timelineItemHover: {
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
@@ -31,6 +31,28 @@ const useStyles = createStyles(({ token }) => {
|
||||
height: '24px',
|
||||
width: '120px',
|
||||
},
|
||||
timelineItemTitle: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
timelineItemTitleText: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
timelineItemTags: {
|
||||
flexShrink: 0,
|
||||
marginLeft: '16px',
|
||||
},
|
||||
creatorTag: {
|
||||
fontSize: '12px',
|
||||
},
|
||||
updaterTag: {
|
||||
fontSize: '12px',
|
||||
},
|
||||
content: {
|
||||
padding: '10px 0',
|
||||
},
|
||||
@@ -52,6 +74,20 @@ const useStyles = createStyles(({ token }) => {
|
||||
marginBottom: '10px',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
dateInfo: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
time: {
|
||||
color: token.colorTextSecondary,
|
||||
},
|
||||
location: {
|
||||
color: token.colorTextTertiary,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
},
|
||||
description: {
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
@@ -97,10 +133,14 @@ const useStyles = createStyles(({ token }) => {
|
||||
color: token.colorText,
|
||||
},
|
||||
timelineItemImages: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', // 减小最小宽度到100px
|
||||
gap: '8px', // 减少间距
|
||||
marginBottom: '20px',
|
||||
maxWidth: '100%', // 确保容器不会超出父元素宽度
|
||||
[`@media (max-width: 768px)`]: {
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(60px, 1fr))', // 在移动设备上减小最小宽度到60px
|
||||
},
|
||||
},
|
||||
timelineImage: {
|
||||
maxWidth: '100%',
|
||||
@@ -116,4 +156,4 @@ const useStyles = createStyles(({ token }) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default useStyles;
|
||||
export default useStyles;
|
||||
@@ -4,9 +4,23 @@ import { StoryItem } from '@/pages/story/data';
|
||||
import { queryStoryItemImages } from '@/pages/story/service';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useIntl, useRequest } from '@umijs/max';
|
||||
import { Button, Divider, Drawer, Popconfirm, Space } from 'antd';
|
||||
import { Button, Divider, Drawer, Popconfirm, Space, Tag } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
if (!time) return '';
|
||||
|
||||
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
|
||||
if (Array.isArray(time)) {
|
||||
const [year, month, day, hour, minute, second] = time;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 如果已经是字符串格式,直接返回
|
||||
return String(time);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
storyItem: StoryItem;
|
||||
open: boolean;
|
||||
@@ -39,9 +53,11 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
};
|
||||
|
||||
// 格式化日期显示
|
||||
const formatDate = (dateString: string) => {
|
||||
const formatDate = (dateString: string | number[] | undefined) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
|
||||
const formattedTime = formatTimeArray(dateString);
|
||||
const date = new Date(formattedTime);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -63,9 +79,23 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
zIndex={1000}
|
||||
title={
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{storyItem.title}</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0 }}>{storyItem.title}</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{storyItem.createName && (
|
||||
<Tag color="blue">
|
||||
创建: {storyItem.createName}
|
||||
</Tag>
|
||||
)}
|
||||
{storyItem.updateName && storyItem.updateName !== storyItem.createName && (
|
||||
<Tag color="green">
|
||||
更新: {storyItem.updateName}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
|
||||
{storyItem.storyItemTime} {storyItem.location ? `于${storyItem.location}` : ''}
|
||||
{formatTimeArray(storyItem.storyItemTime)} {storyItem.location ? `于${storyItem.location}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -158,9 +188,13 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
<div style={{ color: '#888', marginBottom: '4px' }}>更新时间</div>
|
||||
<div style={{ fontSize: '16px' }}>{formatDate(storyItem.updateTime)}</div>
|
||||
</div>
|
||||
<div style={{ flex: '1', minWidth: '200px' }}>
|
||||
<div style={{ color: '#888', marginBottom: '4px' }}>创建人</div>
|
||||
<div style={{ fontSize: '16px' }}>{storyItem.createName || '系统用户'}</div>
|
||||
</div>
|
||||
<div style={{ flex: '1', minWidth: '200px' }}>
|
||||
<div style={{ color: '#888', marginBottom: '4px' }}>更新人</div>
|
||||
<div style={{ fontSize: '16px' }}>系统用户</div>
|
||||
<div style={{ fontSize: '16px' }}>{storyItem.updateName || storyItem.createName || '系统用户'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,4 +204,4 @@ const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineItemDrawer;
|
||||
export default TimelineItemDrawer;
|
||||
10
src/pages/story/data.d.ts
vendored
10
src/pages/story/data.d.ts
vendored
@@ -66,6 +66,14 @@ export interface StoryItem {
|
||||
images?: string[]; // 多张图片
|
||||
subItems?: StoryItem[];
|
||||
isRoot: number;
|
||||
updateId?: string;
|
||||
updateName?: string;
|
||||
createId?: string;
|
||||
createName?: string;
|
||||
}
|
||||
export interface StoryItemTimeQueryParams {
|
||||
beforeTime?: string;
|
||||
afterTime?: string;
|
||||
}
|
||||
export interface AddStoryItem extends StoryItem{
|
||||
file: FormData;
|
||||
@@ -81,3 +89,5 @@ export interface TimelineEvent {
|
||||
images?: string[]; // 多张图片
|
||||
subItems?: TimelineEvent[];
|
||||
}
|
||||
|
||||
export type PermissionType = ''
|
||||
@@ -1,22 +1,39 @@
|
||||
// src/pages/story/detail.tsx
|
||||
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
||||
import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
|
||||
import { StoryItem } from '@/pages/story/data';
|
||||
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
|
||||
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
|
||||
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { history, useRequest } from '@umijs/max';
|
||||
import { FloatButton, Spin, Empty, Button } from 'antd';
|
||||
import { Button, Empty, FloatButton, message, Spin } from 'antd';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import './index.css';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
if (!time) return '';
|
||||
|
||||
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
|
||||
if (Array.isArray(time)) {
|
||||
const [year, month, day, hour, minute, second] = time;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// 如果已经是字符串格式,直接返回
|
||||
return String(time);
|
||||
};
|
||||
|
||||
const Index = () => {
|
||||
const { id: lineId } = useParams<{ id: string }>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<any>(null);
|
||||
const outerRef = useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<StoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
|
||||
@@ -33,10 +50,23 @@ const Index = () => {
|
||||
const measuredItemsRef = useRef<Set<string>>(new Set());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
|
||||
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
|
||||
const [currentTimeArr, setCurrentTimeArr] = useState<[string | undefined, string | undefined]>([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
const [loadDirection, setLoadDirection] = useState<'init' | 'older' | 'newer' | 'refresh'>(
|
||||
'init',
|
||||
);
|
||||
// 添加在其他 useRef 之后
|
||||
const hasShownNoMoreOldRef = useRef(false);
|
||||
const hasShownNoMoreNewRef = useRef(false);
|
||||
const topLoadingRef = useRef(false); // 防止顶部循环加载
|
||||
|
||||
type QueryParams = StoryItemTimeQueryParams & { current?: number; pageSize?: number };
|
||||
|
||||
const { data: response, run } = useRequest(
|
||||
() => {
|
||||
return queryStoryItem({ storyInstanceId: lineId, ...pagination });
|
||||
(params?: QueryParams) => {
|
||||
return queryStoryItem({ storyInstanceId: lineId, pageSize: pagination.pageSize, ...params });
|
||||
},
|
||||
{
|
||||
manual: true,
|
||||
@@ -53,100 +83,100 @@ const Index = () => {
|
||||
|
||||
// 初始化加载数据
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setPagination({ current: 1, pageSize: 10 });
|
||||
setHasMoreOld(true);
|
||||
setHasMoreNew(true);
|
||||
// 重置高度缓存
|
||||
setItemSizes({});
|
||||
measuredItemsRef.current = new Set();
|
||||
run();
|
||||
setLoadDirection('init');
|
||||
setLoading(true);
|
||||
run({ current: 1 });
|
||||
}, [lineId]);
|
||||
|
||||
// 处理响应数据
|
||||
useEffect(() => {
|
||||
if (!response) return;
|
||||
const fetched = response.list || [];
|
||||
const pageSize = pagination.pageSize;
|
||||
const noMore = !(fetched.length === pageSize);
|
||||
|
||||
if (pagination.current === 1) {
|
||||
// 首页数据
|
||||
setItems(response.list || []);
|
||||
} else if (pagination.current > 1) {
|
||||
// 追加更老的数据
|
||||
setItems(prev => [...prev, ...(response.list || [])]);
|
||||
} else if (pagination.current < 1) {
|
||||
// 在前面插入更新的数据
|
||||
setItems(prev => [...(response.list || []), ...prev]);
|
||||
// 保持滚动位置
|
||||
setTimeout(() => {
|
||||
if (listRef.current && response.list) {
|
||||
listRef.current.scrollToItem(response.list.length, 'start');
|
||||
}
|
||||
}, 0);
|
||||
// 若无新数据则避免触发列表重绘,只更新加载状态
|
||||
if (!fetched.length) {
|
||||
if (loadDirection === 'older') {
|
||||
setHasMoreOld(false);
|
||||
} else if (loadDirection === 'newer') {
|
||||
setHasMoreNew(false);
|
||||
topLoadingRef.current = false;
|
||||
}
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
setLoadDirection('init');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
if (pagination.current >= 1) {
|
||||
// 检查是否有更老的数据
|
||||
setHasMoreOld(response.list && response.list.length === pagination.pageSize);
|
||||
} else if (pagination.current < 1) {
|
||||
// 检查是否有更新的数据
|
||||
setHasMoreNew(response.list && response.list.length === pagination.pageSize);
|
||||
if (loadDirection === 'older') {
|
||||
setItems((prev) => {
|
||||
const next = [...prev, ...fetched];
|
||||
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
|
||||
return next;
|
||||
});
|
||||
setHasMoreOld(!noMore);
|
||||
if (noMore && !hasShownNoMoreOldRef.current) {
|
||||
hasShownNoMoreOldRef.current = true;
|
||||
message.info('没有更多历史内容了');
|
||||
}
|
||||
} else if (loadDirection === 'newer') {
|
||||
setItems((prev) => {
|
||||
const next = [...fetched, ...prev];
|
||||
setCurrentTimeArr([next[0]?.storyItemTime, next[next.length - 1]?.storyItemTime]);
|
||||
if (listRef.current && fetched.length) {
|
||||
requestAnimationFrame(() => listRef.current?.scrollToItem(fetched.length + 1, 'start'));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setHasMoreNew(!noMore);
|
||||
if (noMore && !hasShownNoMoreNewRef.current) {
|
||||
hasShownNoMoreNewRef.current = true;
|
||||
message.info('没有更多更新内容了');
|
||||
}
|
||||
topLoadingRef.current = false;
|
||||
} else if (loadDirection === 'refresh') {
|
||||
topLoadingRef.current = false;
|
||||
} else {
|
||||
setItems(fetched);
|
||||
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
|
||||
setHasMoreOld(!noMore);
|
||||
setHasMoreNew(true);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}, [response, pagination]);
|
||||
|
||||
// 滚动到底部加载更老的数据
|
||||
const loadOlder = useCallback(() => {
|
||||
if (loading || !hasMoreOld || pagination.current < 1) return;
|
||||
|
||||
setLoading(true);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: prev.current + 1
|
||||
}));
|
||||
}, [loading, hasMoreOld, pagination]);
|
||||
|
||||
// 滚动到顶部加载更新的数据
|
||||
const loadNewer = useCallback(() => {
|
||||
if (loading || !hasMoreNew || pagination.current > 1 || isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: prev.current - 1
|
||||
}));
|
||||
}, [loading, hasMoreNew, pagination, isRefreshing]);
|
||||
|
||||
// 当分页变化时重新请求数据
|
||||
useEffect(() => {
|
||||
if (pagination.current !== 1) {
|
||||
run();
|
||||
}
|
||||
}, [pagination, run]);
|
||||
setLoadDirection('init');
|
||||
}, [response, loadDirection, pagination.pageSize]);
|
||||
|
||||
// 获取item高度的函数
|
||||
const getItemSize = useCallback((index: number) => {
|
||||
const getItemSize = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (!item) return 300; // 默认高度
|
||||
if (!item) return 400; // 默认高度
|
||||
const key = String(item.id ?? item.instanceId);
|
||||
|
||||
// 如果已经测量过该item的高度,则使用缓存的值
|
||||
if (itemSizes[item.id]) {
|
||||
return itemSizes[item.id];
|
||||
if (itemSizes[key]) {
|
||||
return itemSizes[key];
|
||||
}
|
||||
|
||||
// 返回默认高度
|
||||
return 300;
|
||||
}, [items, itemSizes]);
|
||||
return 400;
|
||||
},
|
||||
[items, itemSizes, loadDirection, loading],
|
||||
);
|
||||
|
||||
// 当item尺寸发生变化时调用
|
||||
const onItemResize = useCallback((itemId: string, height: number) => {
|
||||
const onItemResize = useCallback(
|
||||
(itemId: string, height: number) => {
|
||||
// 只有当高度发生变化时才更新
|
||||
if (itemSizes[itemId] !== height) {
|
||||
setItemSizes(prev => ({
|
||||
setItemSizes((prev) => ({
|
||||
...prev,
|
||||
[itemId]: height
|
||||
[itemId]: height,
|
||||
}));
|
||||
|
||||
// 通知List组件重新计算尺寸
|
||||
@@ -154,47 +184,85 @@ const Index = () => {
|
||||
listRef.current.resetAfterIndex(0);
|
||||
}
|
||||
}
|
||||
}, [itemSizes]);
|
||||
},
|
||||
[itemSizes],
|
||||
);
|
||||
// 滚动到底部加载更老的数据
|
||||
const loadOlder = useCallback(() => {
|
||||
if (loading || !hasMoreOld) {
|
||||
if (!hasMoreOld && !loading) {
|
||||
message.info('没有更多历史内容了');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const beforeTime = items[items.length - 1]?.storyItemTime || currentTimeArr[1];
|
||||
if (!beforeTime) return;
|
||||
const nextPage = pagination.current + 1;
|
||||
setPagination((prev) => ({ ...prev, current: nextPage }));
|
||||
setLoadDirection('older');
|
||||
setLoading(true);
|
||||
run({ current: nextPage });
|
||||
}, [loading, hasMoreOld, items, currentTimeArr, pagination.current, run]);
|
||||
|
||||
// 滚动到顶部加载更新的数据
|
||||
const loadNewer = useCallback(() => {
|
||||
if (loading || !hasMoreNew || isRefreshing || topLoadingRef.current) {
|
||||
if (!hasMoreNew && !loading && !isRefreshing) {
|
||||
message.info('没有更多更新内容了');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const afterTime = items[0]?.storyItemTime || currentTimeArr[0];
|
||||
if (!afterTime) return;
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
setLoadDirection('newer');
|
||||
setIsRefreshing(true);
|
||||
setLoading(true);
|
||||
topLoadingRef.current = true;
|
||||
run({ current: 1 });
|
||||
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
|
||||
|
||||
// 渲染单个时间线项的函数
|
||||
const renderTimelineItem = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
// 显示加载指示器的条件
|
||||
const showOlderLoading = index === items.length && hasMoreOld && pagination.current >= 1;
|
||||
const showNewerLoading = index === 0 && hasMoreNew && pagination.current < 1 && isRefreshing;
|
||||
|
||||
if (showOlderLoading || showNewerLoading) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Spin />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{showNewerLoading ? '正在加载更新的内容...' : '正在加载更多内容...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const item = items[index];
|
||||
if (!item) return null;
|
||||
const key = String(item.id ?? item.instanceId);
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
// 当元素被渲染时测量其实际高度
|
||||
if (el && !measuredItemsRef.current.has(item.id)) {
|
||||
measuredItemsRef.current.add(item.id);
|
||||
if (el && !measuredItemsRef.current.has(key)) {
|
||||
measuredItemsRef.current.add(key);
|
||||
// 使用requestAnimationFrame确保DOM已经渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
if (el) {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
onItemResize(item.id, height);
|
||||
onItemResize(key, height);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
margin: '12px 0',
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
|
||||
border: '1px solid #f0f0f0',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(-2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 4px rgba(0,0,0,0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<TimelineItem
|
||||
item={item}
|
||||
@@ -208,10 +276,13 @@ const Index = () => {
|
||||
}}
|
||||
refresh={() => {
|
||||
// 刷新当前页数据
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
run();
|
||||
hasShownNoMoreOldRef.current = false;
|
||||
hasShownNoMoreNewRef.current = false;
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
@@ -219,34 +290,61 @@ const Index = () => {
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[items, hasMoreOld, hasMoreNew, pagination, isRefreshing, onItemResize, run, queryDetail],
|
||||
[items, hasMoreOld, loadDirection, loading, onItemResize, run, queryDetail],
|
||||
);
|
||||
|
||||
// 处理滚动事件
|
||||
const handleItemsRendered = useCallback(({ visibleStartIndex, visibleStopIndex }) => {
|
||||
// 当可视区域接近列表顶部时加载更新的数据
|
||||
if (visibleStartIndex <= 3 && hasMoreNew && !isRefreshing && pagination.current >= 1) {
|
||||
loadNewer();
|
||||
}
|
||||
// 使用 IntersectionObserver 监听顶部/底部哨兵实现无限滚动
|
||||
useEffect(() => {
|
||||
const root = outerRef.current;
|
||||
const topEl = topSentinelRef.current;
|
||||
const bottomEl = bottomSentinelRef.current;
|
||||
if (!root || !topEl || !bottomEl) return;
|
||||
|
||||
// 当可视区域接近列表底部时加载更老的数据
|
||||
if (visibleStopIndex >= items.length - 3 && hasMoreOld && !loading && pagination.current >= 1) {
|
||||
loadOlder();
|
||||
}
|
||||
const topObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadNewer();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '120px 0px 0px 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
// 控制回到顶部按钮的显示
|
||||
setShowScrollTop(visibleStartIndex > 5);
|
||||
}, [hasMoreNew, hasMoreOld, isRefreshing, loading, items.length, pagination, loadNewer, loadOlder]);
|
||||
const bottomObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
loadOlder();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '0px 0px 120px 0px', threshold: 0 },
|
||||
);
|
||||
|
||||
topObserver.observe(topEl);
|
||||
bottomObserver.observe(bottomEl);
|
||||
|
||||
// 仅用于显示"回到顶部"按钮
|
||||
const handleScroll = () => {
|
||||
const { scrollTop } = root;
|
||||
setShowScrollTop(scrollTop > 200);
|
||||
};
|
||||
root.addEventListener('scroll', handleScroll);
|
||||
|
||||
return () => {
|
||||
topObserver.disconnect();
|
||||
bottomObserver.disconnect();
|
||||
root.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [loadNewer, loadOlder]);
|
||||
|
||||
// 手动刷新最新数据
|
||||
const handleRefresh = () => {
|
||||
if (isRefreshing) return;
|
||||
|
||||
setIsRefreshing(true);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: 0 // 使用0作为刷新标识
|
||||
}));
|
||||
setLoadDirection('refresh');
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
hasShownNoMoreOldRef.current = false;
|
||||
hasShownNoMoreNewRef.current = false;
|
||||
run({ current: 1 });
|
||||
};
|
||||
|
||||
// 回到顶部
|
||||
@@ -256,34 +354,6 @@ const Index = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动事件,动态显示/隐藏提示信息
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (containerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
|
||||
// 判断是否在顶部
|
||||
const isTop = scrollTop === 0;
|
||||
// 判断是否在底部
|
||||
const isBottom = scrollTop + clientHeight >= scrollHeight;
|
||||
|
||||
setHasMoreNew(isTop && hasMoreNew); // 更新顶部提示的显示状态
|
||||
setHasMoreOld(isBottom && hasMoreOld); // 更新底部提示的显示状态
|
||||
}
|
||||
};
|
||||
|
||||
const timelineContainer = containerRef.current;
|
||||
if (timelineContainer) {
|
||||
timelineContainer.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timelineContainer) {
|
||||
timelineContainer.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, [hasMoreNew, hasMoreOld]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
onBack={() => history.push('/story')}
|
||||
@@ -293,7 +363,12 @@ const Index = () => {
|
||||
extra={
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleRefresh}
|
||||
onClick={() => {
|
||||
setItems([]);
|
||||
setPagination({ current: 1, pageSize: 10 });
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
}}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
刷新
|
||||
@@ -306,68 +381,50 @@ const Index = () => {
|
||||
style={{
|
||||
height: 'calc(100vh - 200px)',
|
||||
overflow: 'hidden',
|
||||
position: 'relative'
|
||||
position: 'relative',
|
||||
padding: '0 16px', // 添加一些内边距
|
||||
}}
|
||||
>
|
||||
{items.length > 0 ? (
|
||||
<>
|
||||
{/* 顶部提示信息 */}
|
||||
{!hasMoreNew && pagination.current <= 1 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: '#fff',
|
||||
zIndex: 10,
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
已加载全部更新内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<>
|
||||
<List
|
||||
ref={listRef}
|
||||
height={height - (hasMoreNew && pagination.current <= 1 ? 40 : 0) - (hasMoreOld && pagination.current >= 1 ? 40 : 0)}
|
||||
itemCount={items.length + (hasMoreOld && pagination.current >= 1 ? 1 : 0) + (hasMoreNew && pagination.current < 1 && isRefreshing ? 1 : 0)}
|
||||
outerRef={outerRef}
|
||||
height={height}
|
||||
itemCount={items.length}
|
||||
itemSize={getItemSize}
|
||||
width={width}
|
||||
onItemsRendered={handleItemsRendered}
|
||||
innerElementType={React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>((props, ref) => (
|
||||
<div ref={ref} {...props}>
|
||||
<div ref={topSentinelRef} style={{ height: 1 }} />
|
||||
{props.children}
|
||||
<div ref={bottomSentinelRef} style={{ height: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
{renderTimelineItem}
|
||||
</List>
|
||||
<div className="load-indicator">{loading && '加载中...'}</div>
|
||||
<div className="no-more-data">{!loading && '已加载全部历史数据'}</div>
|
||||
</>
|
||||
)}
|
||||
</AutoSizer>
|
||||
|
||||
{/* 底部提示信息 */}
|
||||
{!hasMoreOld && pagination.current >= 1 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
color: '#999',
|
||||
fontSize: '14px',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
backgroundColor: '#fff',
|
||||
zIndex: 10,
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
已加载全部历史内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 回到顶部按钮 */}
|
||||
{showScrollTop && (
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
zIndex: 10
|
||||
}}>
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
@@ -378,64 +435,93 @@ const Index = () => {
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16 }}>正在加载时间线数据...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Empty
|
||||
description="暂无时间线数据"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => {
|
||||
setCurrentOption('add');
|
||||
setCurrentItem();
|
||||
setOpenAddItemModal(true);
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
fontSize: '16px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
添加第一个时刻
|
||||
</Button>
|
||||
正在加载时间线数据...
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Empty
|
||||
description="暂无时间线数据"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
imageStyle={{
|
||||
height: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#999',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
还没有添加任何时刻
|
||||
</div>
|
||||
</Empty>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setCurrentOption('add');
|
||||
setCurrentItem(undefined);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
>
|
||||
添加第一个时刻
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FloatButton
|
||||
onClick={() => {
|
||||
setCurrentOption('add');
|
||||
setCurrentItem();
|
||||
setCurrentItem(undefined);
|
||||
setOpenAddItemModal(true);
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
style={{
|
||||
right: 24,
|
||||
bottom: 24,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddTimeLineItemModal
|
||||
visible={openAddItemModal}
|
||||
initialValues={currentItem}
|
||||
option={currentOption}
|
||||
option={currentOption || 'add'}
|
||||
onCancel={() => {
|
||||
setOpenAddItemModal(false);
|
||||
}}
|
||||
onOk={() => {
|
||||
setOpenAddItemModal(false);
|
||||
// 添加新项后刷新数据
|
||||
setPagination(prev => ({ ...prev, current: 1 }));
|
||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||
// 重置高度测量
|
||||
measuredItemsRef.current = new Set();
|
||||
run();
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
storyId={lineId}
|
||||
@@ -444,4 +530,4 @@ const Index = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default Index;
|
||||
@@ -8,6 +8,7 @@ import OperationModal from './components/OperationModal';
|
||||
import type { StoryType } from './data.d';
|
||||
import { addStory, deleteStory, queryTimelineList, updateStory } from './service';
|
||||
import useStyles from './style.style';
|
||||
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
|
||||
|
||||
|
||||
const { Search } = Input;
|
||||
@@ -47,6 +48,7 @@ export const BasicList: FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const [done, setDone] = useState<boolean>(false);
|
||||
const [open, setVisible] = useState<boolean>(false);
|
||||
const [authorizeModelOpen, setAuthorizeModelOpen] = useState<boolean>(false);
|
||||
const [current, setCurrent] = useState<Partial<StoryType> | undefined>(undefined);
|
||||
const {
|
||||
data: listData,
|
||||
@@ -195,7 +197,26 @@ export const BasicList: FC = () => {
|
||||
>
|
||||
编辑
|
||||
</a>,
|
||||
<MoreBtn key="more" item={item} />,
|
||||
// 增加授权操作,可以授权给其他用户
|
||||
<a
|
||||
key="authorize"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setCurrent(item);
|
||||
setAuthorizeModelOpen(true);
|
||||
}}
|
||||
>
|
||||
授权
|
||||
</a>,
|
||||
<a
|
||||
key="delete"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
deleteItem(item.instanceId ?? '');
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</a>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
@@ -225,6 +246,17 @@ export const BasicList: FC = () => {
|
||||
onDone={handleDone}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<AuthorizeStoryModal
|
||||
open={authorizeModelOpen}
|
||||
current={current}
|
||||
handleOk={(flag) => {
|
||||
if(flag) {
|
||||
run();
|
||||
}
|
||||
setAuthorizeModelOpen(false);
|
||||
setCurrent({});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { request } from '@umijs/max';
|
||||
import { StoryItem, StoryType } from './data.d';
|
||||
import {StoryItem, StoryItemTimeQueryParams, StoryType} from './data.d';
|
||||
import {CommonListResponse, CommonResponse} from "@/types/common";
|
||||
|
||||
type ParamsType = {
|
||||
@@ -8,12 +8,12 @@ type ParamsType = {
|
||||
storyName?: string;
|
||||
pageSize?: number;
|
||||
current?: number;
|
||||
} & Partial<StoryType> & Partial<StoryItem>;
|
||||
} & Partial<StoryType> & Partial<StoryItem> & Partial<StoryItemTimeQueryParams>;
|
||||
|
||||
export async function queryTimelineList(
|
||||
params: ParamsType,
|
||||
): Promise<{ data: StoryType[] }> {
|
||||
return await request('/story/owner/test11', {
|
||||
return await request('/story/list', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -56,6 +56,14 @@ export async function addStoryItem(params: FormData): Promise<any> {
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
export async function updateStoryItem(params: FormData): Promise<any> {
|
||||
return request(`/story/item`, {
|
||||
method: 'PUT',
|
||||
data: params,
|
||||
requestType: 'form',
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryStoryItem(params: ParamsType): Promise<{ data: CommonListResponse<StoryItem> }> {
|
||||
return request(`/story/item/list`, {
|
||||
@@ -93,3 +101,10 @@ export async function fetchImage(imageInstanceId: string): Promise<any> {
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function authorizeStoryPermission(params: {userId: string, storyInstanceId: string, permissionType: number}) {
|
||||
return request('/story/permission/authorize', {
|
||||
method: 'POST',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Footer } from '@/components';
|
||||
import { login } from '@/services/ant-design-pro/api';
|
||||
import { getFakeCaptcha } from '@/services/ant-design-pro/login';
|
||||
import {
|
||||
AlipayCircleOutlined,
|
||||
@@ -15,12 +14,14 @@ import {
|
||||
ProFormCheckbox,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, Helmet, SelectLang, useIntl, useModel } from '@umijs/max';
|
||||
import { FormattedMessage, Helmet, SelectLang, useIntl, useModel, history, useRequest } from '@umijs/max';
|
||||
import { Alert, message, Tabs } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import React, { useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import Settings from '../../../../config/defaultSettings';
|
||||
import { loginUser } from '@/services/user/api';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
@@ -51,8 +52,7 @@ const useStyles = createStyles(({ token }) => {
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
backgroundImage:
|
||||
"url('https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/V-_oS6r-i7wAAAAAAAAAAAAAFl94AQBr')",
|
||||
backgroundImage: "url('@~/assets/timeline_login.png')",
|
||||
backgroundSize: '100% 100%',
|
||||
},
|
||||
};
|
||||
@@ -102,44 +102,37 @@ const Login: React.FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const intl = useIntl();
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
const userInfo = await initialState?.fetchUserInfo?.();
|
||||
if (userInfo) {
|
||||
flushSync(() => {
|
||||
setInitialState((s) => ({
|
||||
...s,
|
||||
currentUser: userInfo,
|
||||
}));
|
||||
});
|
||||
}
|
||||
};
|
||||
// 使用元组参数签名以匹配 useRequest 重载,避免被分页重载推断
|
||||
const { loading: submitting, run: login } = useRequest<CommonResponse<UserLoginResult>, [UserLoginParams]>(
|
||||
(params: UserLoginParams): Promise<CommonResponse<UserLoginResult>> => loginUser(params),
|
||||
{
|
||||
manual: true,
|
||||
formatResult: (res) => res,
|
||||
onSuccess: async (response: CommonResponse<UserLoginResult>, params: [UserLoginParams]) => {
|
||||
console.log('登录成功 - response:', response, 'params:', params);
|
||||
const [loginParams] = params;
|
||||
const logStatus = { type: loginParams.loginType };
|
||||
if (response.code === 200) {
|
||||
const defaultLoginSuccessMessage = intl.formatMessage({
|
||||
id: 'pages.login.success',
|
||||
defaultMessage: '登录成功!',
|
||||
});
|
||||
message.success(defaultLoginSuccessMessage);
|
||||
// await fetchUserInfo();
|
||||
localStorage.setItem('timeline_user', JSON.stringify(response.data))
|
||||
const urlParams = new URL(window.location.href).searchParams;
|
||||
window.location.href = urlParams.get('redirect') || '/';
|
||||
return;
|
||||
}
|
||||
console.log(response.message);
|
||||
// 如果失败去设置用户错误信息
|
||||
setUserLoginState(response.message as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = async (values: API.LoginParams) => {
|
||||
try {
|
||||
// 登录
|
||||
const msg = await login({ ...values, type });
|
||||
if (msg.status === 'ok') {
|
||||
const defaultLoginSuccessMessage = intl.formatMessage({
|
||||
id: 'pages.login.success',
|
||||
defaultMessage: '登录成功!',
|
||||
});
|
||||
message.success(defaultLoginSuccessMessage);
|
||||
await fetchUserInfo();
|
||||
const urlParams = new URL(window.location.href).searchParams;
|
||||
window.location.href = urlParams.get('redirect') || '/';
|
||||
return;
|
||||
}
|
||||
console.log(msg);
|
||||
// 如果失败去设置用户错误信息
|
||||
setUserLoginState(msg);
|
||||
} catch (error) {
|
||||
const defaultLoginFailureMessage = intl.formatMessage({
|
||||
id: 'pages.login.failure',
|
||||
defaultMessage: '登录失败,请重试!',
|
||||
});
|
||||
console.log(error);
|
||||
message.error(defaultLoginFailureMessage);
|
||||
}
|
||||
await login(values as UserLoginParams);
|
||||
};
|
||||
const { status, type: loginType } = userLoginState;
|
||||
|
||||
@@ -167,24 +160,25 @@ const Login: React.FC = () => {
|
||||
maxWidth: '75vw',
|
||||
}}
|
||||
logo={<img alt="logo" src="/logo.svg" />}
|
||||
title="Ant Design"
|
||||
title="Timeline"
|
||||
subTitle={intl.formatMessage({ id: 'pages.layouts.userLayout.title' })}
|
||||
initialValues={{
|
||||
autoLogin: true,
|
||||
}}
|
||||
actions={[
|
||||
loading={submitting}
|
||||
/*actions={[
|
||||
<FormattedMessage
|
||||
key="loginWith"
|
||||
id="pages.login.loginWith"
|
||||
defaultMessage="其他登录方式"
|
||||
/>,
|
||||
<ActionIcons key="icons" />,
|
||||
]}
|
||||
onFinish={async (values) => {
|
||||
await handleSubmit(values as API.LoginParams);
|
||||
}}
|
||||
]}*/
|
||||
onFinish={async (values) => {
|
||||
await login({ ...values, loginType: type } as UserLoginParams);
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
{/*<Tabs
|
||||
activeKey={type}
|
||||
onChange={setType}
|
||||
centered
|
||||
@@ -204,7 +198,7 @@ const Login: React.FC = () => {
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
/>*/}
|
||||
|
||||
{status === 'error' && loginType === 'account' && (
|
||||
<LoginMessage
|
||||
@@ -224,7 +218,6 @@ const Login: React.FC = () => {
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'pages.login.username.placeholder',
|
||||
defaultMessage: '用户名: admin or user',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
@@ -246,7 +239,6 @@ const Login: React.FC = () => {
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'pages.login.password.placeholder',
|
||||
defaultMessage: '密码: ant.design',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
@@ -348,18 +340,20 @@ const Login: React.FC = () => {
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<ProFormCheckbox noStyle name="autoLogin">
|
||||
<FormattedMessage id="pages.login.rememberMe" defaultMessage="自动登录" />
|
||||
</ProFormCheckbox>
|
||||
<a
|
||||
style={{
|
||||
float: 'right',
|
||||
}}
|
||||
>
|
||||
<FormattedMessage id="pages.login.forgotPassword" defaultMessage="忘记密码" />
|
||||
</a>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<a onClick={() => history.push('/user/register')}>立即注册</a>
|
||||
<a>
|
||||
<FormattedMessage id="pages.login.forgotPassword" defaultMessage="忘记密码" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</LoginForm>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useEffect, useState } from 'react';
|
||||
import type { StateType } from './service';
|
||||
import { fakeRegister } from './service';
|
||||
import useStyles from './style.style';
|
||||
import { registerUser } from '@/services/user/api';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
const { Option } = Select;
|
||||
@@ -74,21 +76,36 @@ const Register: FC = () => {
|
||||
}
|
||||
return 'poor';
|
||||
};
|
||||
const { loading: submitting, run: register } = useRequest<{
|
||||
data: StateType;
|
||||
}>(fakeRegister, {
|
||||
manual: true,
|
||||
onSuccess: (data, params) => {
|
||||
if (data.status === 'ok') {
|
||||
message.success('注册成功!');
|
||||
history.push({
|
||||
pathname: `/user/register-result?account=${params[0].email}`,
|
||||
});
|
||||
}
|
||||
const { loading: submitting, run: register } = useRequest(
|
||||
(params) => registerUser(params, { skipErrorHandler: true }),
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: (data, params) => {
|
||||
console.log('注册成功 - data:', data, 'params:', params);
|
||||
const response = data as CommonResponse<string>;
|
||||
if (response?.code === 200) {
|
||||
message.success('注册成功!');
|
||||
const formValues = params[0] as any;
|
||||
history.push({
|
||||
pathname: `/user/register-result?account=${formValues?.email || formValues?.username || ''}`,
|
||||
});
|
||||
} else {
|
||||
message.error(response?.message || '注册失败,请重试');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const onFinish = (values: Store) => {
|
||||
register(values);
|
||||
// 将表单数据映射为后端需要的格式
|
||||
const registerParams = {
|
||||
username: values.username,
|
||||
nickname: values.nickname,
|
||||
password: values.password,
|
||||
email: values.email || '',
|
||||
phone: values.phone || '',
|
||||
};
|
||||
register(registerParams);
|
||||
};
|
||||
const checkConfirm = (_: any, value: string) => {
|
||||
const promise = Promise;
|
||||
@@ -139,19 +156,22 @@ const Register: FC = () => {
|
||||
<h3>注册</h3>
|
||||
<Form form={form} name="UserRegister" onFinish={onFinish}>
|
||||
<FormItem
|
||||
name="email"
|
||||
name="username"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入邮箱地址!',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '邮箱地址格式错误!',
|
||||
},
|
||||
{ required: true, message: '请输入用户名!' },
|
||||
{ max: 50, message: '用户名过长' },
|
||||
]}
|
||||
>
|
||||
<Input size="large" placeholder="邮箱" />
|
||||
<Input size="large" placeholder="用户名" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="nickname"
|
||||
rules={[
|
||||
{ required: true, message: '请输入昵称!' },
|
||||
{ max: 50, message: '昵称过长' },
|
||||
]}
|
||||
>
|
||||
<Input size="large" placeholder="昵称" />
|
||||
</FormItem>
|
||||
<Popover
|
||||
getPopupContainer={(node) => {
|
||||
@@ -216,8 +236,23 @@ const Register: FC = () => {
|
||||
<Input size="large" type="password" placeholder="确认密码" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="mobile"
|
||||
rules={[
|
||||
name="email"
|
||||
/* rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入邮箱地址!',
|
||||
},
|
||||
{
|
||||
type: 'email',
|
||||
message: '邮箱地址格式错误!',
|
||||
},
|
||||
]} */
|
||||
>
|
||||
<Input size="large" placeholder="邮箱" />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="phone"
|
||||
/* rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入手机号!',
|
||||
@@ -226,7 +261,7 @@ const Register: FC = () => {
|
||||
pattern: /^\d{11}$/,
|
||||
message: '手机号格式错误!',
|
||||
},
|
||||
]}
|
||||
]} */
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Select
|
||||
@@ -244,7 +279,7 @@ const Register: FC = () => {
|
||||
<Input size="large" placeholder="手机号" />
|
||||
</Space.Compact>
|
||||
</FormItem>
|
||||
<Row gutter={8}>
|
||||
{/* <Row gutter={8}>
|
||||
<Col span={16}>
|
||||
<FormItem
|
||||
name="captcha"
|
||||
@@ -268,7 +303,7 @@ const Register: FC = () => {
|
||||
{count ? `${count} s` : '获取验证码'}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Row> */}
|
||||
<FormItem>
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RequestOptions } from '@@/plugin-request/request';
|
||||
import type { RequestOptions } from '@@/plugin-request/request';
|
||||
import type { RequestConfig } from '@umijs/max';
|
||||
import { history } from '@umijs/max';
|
||||
import { message, notification } from 'antd';
|
||||
|
||||
// 错误处理方案: 错误类型
|
||||
@@ -71,6 +72,11 @@ export const errorConfig: RequestConfig = {
|
||||
}
|
||||
} else if (error.response) {
|
||||
// Axios 的错误
|
||||
if (error.response.status === 401) {
|
||||
message.warning('请先登录');
|
||||
history.push(`/user/login?redirect=${window.location.href}`);
|
||||
return;
|
||||
}
|
||||
// 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
|
||||
message.error(`Response status:${error.response.status}`);
|
||||
} else if (error.request) {
|
||||
@@ -88,9 +94,49 @@ export const errorConfig: RequestConfig = {
|
||||
// 请求拦截器
|
||||
requestInterceptors: [
|
||||
(config: RequestOptions) => {
|
||||
// 拦截请求配置,进行个性化处理。
|
||||
const url = config?.url?.concat('?token = 123');
|
||||
return { ...config, url };
|
||||
// 登录/注册接口不附加 token
|
||||
const skipTokenUrls = ['/user-api/auth/login', '/user-api/auth/register'];
|
||||
if (config?.url && skipTokenUrls.some((u) => config.url?.startsWith(u))) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 其余请求自动附带本地存储的 token
|
||||
let token: string | undefined;
|
||||
const cached = localStorage.getItem('timeline_user');
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
token = parsed?.accessToken;
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const headers = {
|
||||
...(config.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
return { ...config, headers };
|
||||
}
|
||||
|
||||
// 无 token 提示并引导登录
|
||||
if (!token) {
|
||||
message.warning('请先登录');
|
||||
// 避免URL参数重复,只在没有redirect参数时才添加
|
||||
const currentUrl = window.location.href;
|
||||
const urlParams = new URL(currentUrl).searchParams;
|
||||
const redirect = urlParams.get('redirect');
|
||||
|
||||
if (redirect) {
|
||||
history.push(`/user/login?redirect=${redirect}`);
|
||||
} else {
|
||||
history.push(`/user/login?redirect=${currentUrl}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
],
|
||||
|
||||
@@ -98,12 +144,16 @@ export const errorConfig: RequestConfig = {
|
||||
responseInterceptors: [
|
||||
(response) => {
|
||||
// 拦截响应数据,进行个性化处理
|
||||
const { data } = response as unknown as ResponseStructure;
|
||||
|
||||
if (data?.success === false) {
|
||||
message.error('请求失败!');
|
||||
// response 是 axios 响应对象,response.data 是后端返回的数据
|
||||
// 返回 response.data 以便获取完整的响应体(包括 code, message, data)
|
||||
const responseData = (response as any);
|
||||
|
||||
if (responseData?.success === false || responseData?.data?.code !== 200) {
|
||||
message.error(responseData.data?.message || '请求失败!');
|
||||
}
|
||||
return response;
|
||||
|
||||
// 返回完整的响应体(包括 code, message, data),而不是只返回 data 字段
|
||||
return responseData || response;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ export async function queryStoryItemImages(itemId: string): Promise<{ data: stri
|
||||
}
|
||||
// 获取图片
|
||||
export async function fetchImage(imageInstanceId: string): Promise<any> {
|
||||
return request(`/file/download/cover/${imageInstanceId}`, {
|
||||
return request(`/file/image/${imageInstanceId}`, {
|
||||
method: 'GET',
|
||||
responseType: 'blob',
|
||||
getResponse: true,
|
||||
|
||||
23
src/services/user/api.ts
Normal file
23
src/services/user/api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CommonResponse } from "@/types/common";
|
||||
import {request} from "@@/exports";
|
||||
|
||||
|
||||
export async function registerUser(params: UserRegisterParams, options?: { skipErrorHandler?: boolean }): Promise<CommonResponse<string>> {
|
||||
return request('/user-api/auth/register', {
|
||||
method: 'POST',
|
||||
data: params,
|
||||
getResponse: true,
|
||||
});
|
||||
}
|
||||
export async function loginUser(params: UserLoginParams): Promise<CommonResponse<UserLoginResult>> {
|
||||
return request('/user-api/auth/login', {
|
||||
method: 'POST',
|
||||
data: params,
|
||||
// getResponse: true,
|
||||
});
|
||||
}
|
||||
export async function logoutUser() {
|
||||
return request('/user-api/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
37
src/services/user/typing.d.ts
vendored
Normal file
37
src/services/user/typing.d.ts
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
// 注册用户参数
|
||||
interface UserRegisterParams {
|
||||
username: string;
|
||||
nickname: string;
|
||||
password: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
// 登录用户参数
|
||||
interface UserLoginParams {
|
||||
username?: string;
|
||||
password?: string;
|
||||
loginType?: string;
|
||||
}
|
||||
// 退出登录参数
|
||||
interface UserLogoutParams {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
// 登录返回参数
|
||||
interface UserLoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresInSeconds: number;
|
||||
refreshTokenExpiresInSeconds: number;
|
||||
}
|
||||
// 用户信息
|
||||
interface UserInfo {
|
||||
username: string,
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresInSeconds: number;
|
||||
refreshTokenExpiresInSeconds: number;
|
||||
phone: number,
|
||||
email: string,
|
||||
avatar: string,
|
||||
}
|
||||
2
src/types/react-window.d.ts
vendored
Normal file
2
src/types/react-window.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'react-window';
|
||||
|
||||
8
src/utils/userUtils.ts
Normal file
8
src/utils/userUtils.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const getUser = () => {
|
||||
return JSON.parse(localStorage.getItem('timeline_user') ?? "{}");
|
||||
}
|
||||
|
||||
export const getAuthization = () => {
|
||||
const user = getUser();
|
||||
return `Bearer+${user.accessToken}`;
|
||||
}
|
||||
Reference in New Issue
Block a user