diff --git a/config/config.ts b/config/config.ts index 83d7a45..9db7b9d 100644 --- a/config/config.ts +++ b/config/config.ts @@ -77,7 +77,7 @@ export default defineConfig({ * @name layout 插件 * @doc https://umijs.org/docs/max/layout-menu */ - title: 'Ant Design Pro', + title: 'Timeline', layout: { locale: true, ...defaultSettings, diff --git a/config/proxy.ts b/config/proxy.ts index e9b3ce1..01401f4 100644 --- a/config/proxy.ts +++ b/config/proxy.ts @@ -9,13 +9,15 @@ * * @doc https://umijs.org/docs/guides/proxy */ +const basePath = 'http://59.80.22.43:33333' +// const basePath = 'http://localhost:30000' export default { // 如果需要自定义本地开发服务器 请取消注释按需调整 dev: { // localhost:8000/api/** -> https://preview.pro.ant.design/api/** '/story/': { // 要代理的地址 - target: 'http://localhost:30001', + target: basePath, // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, @@ -23,12 +25,20 @@ export default { }, '/file/': { // 要代理的地址 - target: 'http://localhost:30002', + target: basePath, // 配置了这个可以从 http 代理到 https // 依赖 origin 的功能可能需要这个,比如 cookie changeOrigin: true, pathRewrite: { '^/file': '/file' }, }, + '/user-api/': { + // 要代理的地址 + target: basePath, + // 配置了这个可以从 http 代理到 https + // 依赖 origin 的功能可能需要这个,比如 cookie + changeOrigin: true, + pathRewrite: { '^/user-api': '/user' }, + }, '/api/': { target: 'https://proapi.azurewebsites.net', changeOrigin: true, diff --git a/config/routes.ts b/config/routes.ts index 9736529..2bb206c 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -43,7 +43,7 @@ export default [ }, ], }, - { + /* { path: '/dashboard', name: 'dashboard', icon: 'dashboard', @@ -71,7 +71,7 @@ export default [ component: './dashboard/workplace', }, ], - }, + }, */ { name: '故事', icon: 'smile', @@ -89,6 +89,22 @@ export default [ component: './story/detail', }, { + name: '用户中心', + icon: 'user', + path: '/account', + component: './account/center', + }, + { + name: '用户设置', + icon: 'setting', + path: '/account/settings', + component: './account/settings', + }, + { + path: '/', + redirect: './account/center', + }, + /* { path: '/', name: 'other', routes: [ @@ -229,5 +245,5 @@ export default [ path: '/user', }, ] - } + } */ ]; diff --git a/package.json b/package.json index 7745719..288de4a 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@antv/l7-maps": "^2.20.13", "@antv/l7-react": "^2.4.3", "@isaacs/cliui": "^8.0.2", + "@stomp/stompjs": "^7.2.1", + "@types/sockjs-client": "^1.5.4", "@umijs/route-utils": "^2.2.2", "antd": "^5.12.7", "antd-img-crop": "^4.25.0", @@ -61,7 +63,8 @@ "react-dom": "^18.3.1", "react-fittext": "^1.0.0", "react-virtualized-auto-sizer": "^1.0.26", - "react-window": "^1.8.11" + "react-window": "^1.8.11", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@ant-design/pro-cli": "^2.1.5", diff --git a/src/app.tsx b/src/app.tsx index 7dda411..82c8613 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -15,10 +15,19 @@ const loginPath = '/user/login'; * */ export async function getInitialState(): Promise<{ settings?: Partial; - currentUser?: API.CurrentUser; + currentUser?: UserInfo; loading?: boolean; - fetchUserInfo?: () => Promise; + 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, }; } return { - fetchUserInfo, + currentUser: cachedUser, + logoutUser, settings: defaultSettings as Partial, }; } @@ -50,13 +63,13 @@ export async function getInitialState(): Promise<{ export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => { return { actionsRender: () => [, ], - /*avatarProps: { + avatarProps: { src: initialState?.currentUser?.avatar, title: , render: (_, avatarChildren) => { return {avatarChildren}; }, - },*/ + }, /*waterMarkProps: { content: initialState?.currentUser?.name, },*/ diff --git a/src/components/Hooks/useAuthImageUrls.ts b/src/components/Hooks/useAuthImageUrls.ts new file mode 100644 index 0000000..05dc0c1 --- /dev/null +++ b/src/components/Hooks/useAuthImageUrls.ts @@ -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>({}); + const [loading, setLoading] = useState>({}); + + useEffect(() => { + const fetchImageUrls = async () => { + const newImageUrls: Record = {}; + const newLoading: Record = { ...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; \ No newline at end of file diff --git a/src/components/Hooks/useFetchHighResImageUrl.ts b/src/components/Hooks/useFetchHighResImageUrl.ts new file mode 100644 index 0000000..1f917b4 --- /dev/null +++ b/src/components/Hooks/useFetchHighResImageUrl.ts @@ -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(); + +const useFetchHighResImageUrl = (imageInstanceId: string) => { + const [imageUrl, setImageUrl] = useState(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; \ No newline at end of file diff --git a/src/components/Hooks/useFetchImageUrl.ts b/src/components/Hooks/useFetchImageUrl.ts index 9d5a8c9..d355c0a 100644 --- a/src/components/Hooks/useFetchImageUrl.ts +++ b/src/components/Hooks/useFetchImageUrl.ts @@ -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(); + 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; diff --git a/src/components/Hooks/useWebSocket.ts b/src/components/Hooks/useWebSocket.ts new file mode 100644 index 0000000..7f8bf1a --- /dev/null +++ b/src/components/Hooks/useWebSocket.ts @@ -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; + protocols?: string | string[]; +} + +const useWebSocket = (url: string, options?: WebSocketOptions) => { + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const ws = useRef(null); + const stompClient = useRef(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; \ No newline at end of file diff --git a/src/components/RightContent/AvatarDropdown.tsx b/src/components/RightContent/AvatarDropdown.tsx index 03e2c0e..89f4a2e 100644 --- a/src/components/RightContent/AvatarDropdown.tsx +++ b/src/components/RightContent/AvatarDropdown.tsx @@ -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 {currentUser?.name}; + return {currentUser?.username}; }; const useStyles = createStyles(({ token }) => { @@ -42,7 +43,13 @@ export const AvatarDropdown: React.FC = ({ 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 = ({ menu, childre const { currentUser } = initialState; - if (!currentUser || !currentUser.name) { + if (!currentUser || !currentUser.username) { return loading; } diff --git a/src/components/TimelineImage/index.tsx b/src/components/TimelineImage/index.tsx index 3e3ca6c..ae2abf7 100644 --- a/src/components/TimelineImage/index.tsx +++ b/src/components/TimelineImage/index.tsx @@ -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) => { 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 (
= (props) => { ); }; -export default TimelineImage; +export default TimelineImage; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index ca88a6d..4b0dedf 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; \ No newline at end of file diff --git a/src/locales/zh-CN/pages.ts b/src/locales/zh-CN/pages.ts index a266bc6..f883eb1 100644 --- a/src/locales/zh-CN/pages.ts +++ b/src/locales/zh-CN/pages.ts @@ -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': '登录失败,请重试!', diff --git a/src/pages/account/center/components/Dynamic/index.tsx b/src/pages/account/center/components/Dynamic/index.tsx new file mode 100644 index 0000000..a2b8518 --- /dev/null +++ b/src/pages/account/center/components/Dynamic/index.tsx @@ -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 ( + + size="large" + itemLayout="vertical" + loading={loading} + dataSource={items} + rowKey={(item) => String(item.id)} + renderItem={(item) => ( + + + {/* 增加点击事件,只允许点击故事实例详情 */} + { + history.push(`/timeline/${item.storyInstanceId}`); + }} style={{ cursor: 'pointer' }} title="点击查看故事实例详情" > + + {item.actorName} 在{item.storyInstanceName}中{`${formatAction(item.action)}`} + {item.storyItemName} + + + + } + description={ + + 发生时间 + {formatTime(item.itemTime)} + 记录时间 + {formatTime(item.activityTime)} + {item.itemLocation && ( + + 地点 + {item.itemLocation} + + )} + + } + /> +
+ 描述: + {item.itemDescription} +
+ {item.remark && ( +
+ 备注: + {item.remark} +
+ )} +
+ )} + /> + ); +}; + +export default Dynamic; \ No newline at end of file diff --git a/src/pages/account/center/components/Friends/index.tsx b/src/pages/account/center/components/Friends/index.tsx new file mode 100644 index 0000000..a720825 --- /dev/null +++ b/src/pages/account/center/components/Friends/index.tsx @@ -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 ( +
+
+ 昵称:{nickname} + 备注:{remark} + 创建时间:{createFriendTime} + 好友状态:{friendStatus} +
+
+ ); +}; + + +// 好友 Tab:展示并管理好友(前端状态占位实现) +const Friends: FC = () => { + + const [searchUsers, setSearchUsers] = useState([]); + const [addModalVisible, setAddModalVisible] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(''); + const [friends, setFriends] = useState([]); + + + 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 ( +
+ + + + ( + handleRemove(item.userId)} + > + + , + ]} + > + {item.username}} + title={item.username} + description={item.description} + /> + + + )} + /> + + setAddModalVisible(false)} + footer={null} + destroyOnClose + > + setSearchKeyword(e.target.value.trim())} + style={{ marginBottom: 16 }} + /> + ( + handleSelectFriend(item)}> + 添加 + , + ]} + > + {item.username}} + title={item.username} + description={item.description} + /> + + )} + /> + +
+ ); +}; + +export default Friends; \ No newline at end of file diff --git a/src/pages/account/center/components/Message/index.tsx b/src/pages/account/center/components/Message/index.tsx new file mode 100644 index 0000000..53dd2cc --- /dev/null +++ b/src/pages/account/center/components/Message/index.tsx @@ -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 = (props) => { + const { messages: wsMessages, sendChatMessage } = props; + const [localMessages, setLocalMessages] = useState([]); + + 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) => { + setLocalMessages((prev) => + prev.map((m) => (m.id === id ? { ...m, status } : m)) as MessageItem[], + ); + }; + + const handleReply = (msg: Extract) => { + 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) => { + try { + await acceptFriendRequest(msg.senderId); + message.success('已接受好友请求'); + updateStatus(msg.id, 'accepted'); + } catch (e: any) { + message.error(e?.message || '接受失败'); + } + }; + + const handleReject = async (msg: Extract) => { + try { + await rejectFriendRequest(msg.senderId); + message.info('已拒绝好友请求'); + updateStatus(msg.id, 'rejected'); + } catch (e: any) { + message.error(e?.message || '拒绝失败'); + } + }; + + return ( + ( + + + + + ) : item.type === 'friend_message' ? ( + + ) : null, + ]} + > + + {item.status && 状态:{item.status}} + + )} + /> + ); +}; +export default Messages; \ No newline at end of file diff --git a/src/pages/account/center/data.d.ts b/src/pages/account/center/data.d.ts index 6085c1e..d824a98 100644 --- a/src/pages/account/center/data.d.ts +++ b/src/pages/account/center/data.d.ts @@ -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; // 允许其他属性 +} \ No newline at end of file diff --git a/src/pages/account/center/index.tsx b/src/pages/account/center/index.tsx index 3c111c1..c50679f 100644 --- a/src/pages/account/center/index.tsx +++ b/src/pages/account/center/index.tsx @@ -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: ( - 文章{' '} + 动态{' '} - 应用{' '} + 好友{' '} - 项目{' '} + 消息{' '} = ({ tags }) => { const { styles } = useStyles(); const ref = useRef(null); @@ -122,20 +124,22 @@ const TagList: React.FC<{
); }; + + const Center: React.FC = () => { const { styles } = useStyles(); - const [tabKey, setTabKey] = useState('articles'); - + const [tabKey, setTabKey] = useState('dynamic'); + const { messages: wsMessages, sendChatMessage } = useWebSocket('/user-api/ws'); // 获取用户信息 const { data: currentUser, loading } = useRequest(() => { return queryCurrent(); }); // 渲染用户信息 - const renderUserInfo = ({ title, group, geographic }: Partial) => { + const renderUserInfo = ({ title, group, location }: Partial) => { return (
-

+ {/*

{ }} /> {group} -

+

*/}

{ }} /> { - ( - geographic || { - province: { - label: '', - }, - } - ).province.label - } - { - ( - geographic || { - city: { - label: '', - }, - } - ).city.label + location || "" }

@@ -182,14 +171,14 @@ const Center: React.FC = () => { // 渲染tab切换 const renderChildrenByTabKey = (tabValue: tabKeyType) => { - if (tabValue === 'projects') { - return ; + if (tabValue === 'dynamic') { + return ; } - if (tabValue === 'applications') { - return ; + if (tabValue === 'friends') { + return ; } - if (tabValue === 'articles') { - return ; + if (tabValue === 'messages') { + return ; } return null; }; @@ -208,12 +197,12 @@ const Center: React.FC = () => {
-
{currentUser.name}
-
{currentUser?.signature}
+
{currentUser.nickname}
+
{currentUser?.description}
{renderUserInfo(currentUser)} - + ({ key: item, label: item })) as TagType[] || []} /> { - 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> { + return request('/user-api/friend/request', { + data: { + friendId: userId, + }, + method: "POST" + }) +} +export async function queryFriendList(): Promise> { + return request('/user-api/friend/list'); +} + +export async function acceptFriendRequest(friendId: string): Promise> { + return request('/user-api/friend/accept', { + method: 'POST', + data: { friendId }, + }); +} + +export async function rejectFriendRequest(friendId: string): Promise> { + return request('/user-api/friend/reject', { + method: 'POST', + data: { friendId }, + }); +} +export async function queryHistoryMessages(): Promise> { + return request('/user-api/message/history/friend', { + method: 'GET', + }); +} +export async function queryFriendDynamic(): Promise> { + return request('/story/activity/authorized-items', { + method: 'GET', + }); +} export async function queryFakeList(params: { count: number; }): Promise<{ data: { list: ListItemDataType[] } }> { diff --git a/src/pages/account/settings/components/base.tsx b/src/pages/account/settings/components/base.tsx index 37448e9..a3d949e 100644 --- a/src/pages/account/settings/components/base.tsx +++ b/src/pages/account/settings/components/base.tsx @@ -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 > - + + { }, ]} /> - - - { - return queryProvince().then(({ data }) => { - return data.map((item) => { - return { - label: item.name, - value: item.id, - }; - }); - }); - }} + + - - {({ province }) => { - return ( - { - if (!province?.key) { - return []; - } - return queryCity(province.key || '').then(({ data }) => { - return data.map((item) => { - return { - label: item.name, - value: item.id, - }; - }); - }); - }} - /> - ); - }} - - - - - - - + +
diff --git a/src/pages/account/settings/service.ts b/src/pages/account/settings/service.ts index fb047d2..f6eb2f0 100644 --- a/src/pages/account/settings/service.ts +++ b/src/pages/account/settings/service.ts @@ -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}> { + return request('/user-api/info', { + method: 'PUT', + data: params, + }) } - export async function queryProvince(): Promise<{ data: GeographicItemType[] }> { return request('/api/geographic/province'); } diff --git a/src/pages/gallery/components/GalleryTable.tsx b/src/pages/gallery/components/GalleryTable.tsx index 4e0fc0e..bd177d5 100644 --- a/src/pages/gallery/components/GalleryTable.tsx +++ b/src/pages/gallery/components/GalleryTable.tsx @@ -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 = ({ 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', diff --git a/src/pages/gallery/components/GridView.tsx b/src/pages/gallery/components/GridView.tsx index 16a829e..71887d1 100644 --- a/src/pages/gallery/components/GridView.tsx +++ b/src/pages/gallery/components/GridView.tsx @@ -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 = ({ ); // 根据视图模式确定图像 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 = ({ 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 = ({ ); }; -export default GridView; +export default GridView; \ No newline at end of file diff --git a/src/pages/gallery/components/ListView.tsx b/src/pages/gallery/components/ListView.tsx index 2c3a385..f222d26 100644 --- a/src/pages/gallery/components/ListView.tsx +++ b/src/pages/gallery/components/ListView.tsx @@ -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 = ({ 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 = ({ ); }; -export default ListView; +export default ListView; \ No newline at end of file diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index d6d3818..edad903 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -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 */} { 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; \ No newline at end of file diff --git a/src/pages/story/components/AddTimeLineItemModal.tsx b/src/pages/story/components/AddTimeLineItemModal.tsx index 507c4f9..609abf4 100644 --- a/src/pages/story/components/AddTimeLineItemModal.tsx +++ b/src/pages/story/components/AddTimeLineItemModal.tsx @@ -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 = ({ } }, [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 = ({ ); }; -export default AddTimeLineItemModal; +export default AddTimeLineItemModal; \ No newline at end of file diff --git a/src/pages/story/components/AuthorizeStoryModal.tsx b/src/pages/story/components/AuthorizeStoryModal.tsx new file mode 100644 index 0000000..4106579 --- /dev/null +++ b/src/pages/story/components/AuthorizeStoryModal.tsx @@ -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 | 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 ( + handleOk(false)} onOk={() => onFinish(form.getFieldsValue())}> + {/* 请选择需要授权的好友 */} +
+ + + + + + +
+
+ ); +}; + +export default AuthorizeStoryModal; diff --git a/src/pages/story/components/OperationModal.tsx b/src/pages/story/components/OperationModal.tsx index 9616c08..ca5c57a 100644 --- a/src/pages/story/components/OperationModal.tsx +++ b/src/pages/story/components/OperationModal.tsx @@ -56,23 +56,29 @@ const OperationModal: FC = (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 = (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 = (props) => { }, ]); }; - img.src = iconPreview; + img.src = iconPreview ?? ''; }} > = (props) => { placeholder="请选择" /> - - ) : ( diff --git a/src/pages/story/components/TimelineItem/TimelineItem.tsx b/src/pages/story/components/TimelineItem/TimelineItem.tsx index 3f55925..af478da 100644 --- a/src/pages/story/components/TimelineItem/TimelineItem.tsx +++ b/src/pages/story/components/TimelineItem/TimelineItem.tsx @@ -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 ( setShowActions(true)} - onMouseLeave={() => setShowActions(false)} + className={`${styles.timelineItem} ${hovered ? styles.timelineItemHover : ''}`} + title={ +
+
{item.title}
+ + {item.createName && ( + + 创建: {item.createName} + + )} + {item.updateName && item.updateName !== item.createName && ( + + 更新: {item.updateName} + + )} + +
+ } + onMouseEnter={() => { + setShowActions(true); + setHovered(true); + }} + onMouseLeave={() => { + setShowActions(false); + setHovered(false); + }} extra={
{showActions && ( @@ -100,12 +139,14 @@ const TimelineItem: React.FC<{ )}
} - // onClick={() => onDetail(item)} hoverable >
setOpenDetail(true)}> - {item.storyItemTime} {item.location ? `创建于${item.location}` : ''} + + {formatTimeArray(item.storyItemTime)} + {item.location && 📍 {item.location}} +
setOpenDetail(true)}> {displayedDescription} @@ -125,13 +166,13 @@ const TimelineItem: React.FC<{
{imagesList && imagesList.length > 0 && ( <> -
+
{imagesList.map((imageInstanceId, index) => ( ))}
@@ -156,7 +197,7 @@ const TimelineItem: React.FC<{ {item.subItems.map((subItem) => (
- {item.storyItemTime} {item.location ? `创建于${item.location}` : ''} + {formatTimeArray(item.storyItemTime)} {item.location ? `创建于${item.location}` : ''}
{subItem.description}
@@ -177,4 +218,4 @@ const TimelineItem: React.FC<{ ); }; -export default TimelineItem; +export default TimelineItem; \ No newline at end of file diff --git a/src/pages/story/components/TimelineItem/index.style.ts b/src/pages/story/components/TimelineItem/index.style.ts index 55120c8..49413a6 100644 --- a/src/pages/story/components/TimelineItem/index.style.ts +++ b/src/pages/story/components/TimelineItem/index.style.ts @@ -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; \ No newline at end of file diff --git a/src/pages/story/components/TimelineItemDrawer.tsx b/src/pages/story/components/TimelineItemDrawer.tsx index b925d31..a6e5d7e 100644 --- a/src/pages/story/components/TimelineItemDrawer.tsx +++ b/src/pages/story/components/TimelineItemDrawer.tsx @@ -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) => { }; // 格式化日期显示 - 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) => { zIndex={1000} title={
-

{storyItem.title}

+
+

{storyItem.title}

+
+ {storyItem.createName && ( + + 创建: {storyItem.createName} + + )} + {storyItem.updateName && storyItem.updateName !== storyItem.createName && ( + + 更新: {storyItem.updateName} + + )} +
+
- {storyItem.storyItemTime} {storyItem.location ? `于${storyItem.location}` : ''} + {formatTimeArray(storyItem.storyItemTime)} {storyItem.location ? `于${storyItem.location}` : ''}
} @@ -158,9 +188,13 @@ const TimelineItemDrawer: React.FC = (props) => {
更新时间
{formatDate(storyItem.updateTime)}
+
+
创建人
+
{storyItem.createName || '系统用户'}
+
更新人
-
系统用户
+
{storyItem.updateName || storyItem.createName || '系统用户'}
@@ -170,4 +204,4 @@ const TimelineItemDrawer: React.FC = (props) => { ); }; -export default TimelineItemDrawer; +export default TimelineItemDrawer; \ No newline at end of file diff --git a/src/pages/story/data.d.ts b/src/pages/story/data.d.ts index 3194fe2..ed5ab60 100644 --- a/src/pages/story/data.d.ts +++ b/src/pages/story/data.d.ts @@ -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 = '' \ No newline at end of file diff --git a/src/pages/story/detail.tsx b/src/pages/story/detail.tsx index d1ddd2c..e70fec6 100644 --- a/src/pages/story/detail.tsx +++ b/src/pages/story/detail.tsx @@ -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(null); const listRef = useRef(null); + const outerRef = useRef(null); + const topSentinelRef = useRef(null); + const bottomSentinelRef = useRef(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据 @@ -33,10 +50,23 @@ const Index = () => { const measuredItemsRef = useRef>(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 ( -
-
- -
- {showNewerLoading ? '正在加载更新的内容...' : '正在加载更多内容...'} -
-
-
- ); - } - const item = items[index]; if (!item) return null; + const key = String(item.id ?? item.instanceId); return (
{ // 当元素被渲染时测量其实际高度 - 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)'; + }} > { }} 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 = () => {
); }, - [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 ( history.push('/story')} @@ -293,7 +363,12 @@ const Index = () => { extra={ + 正在加载时间线数据... +
+ ) : ( + <> + +
+ 还没有添加任何时刻 +
+
+ + )} )} - { setCurrentOption('add'); - setCurrentItem(); + setCurrentItem(undefined); setOpenAddItemModal(true); }} + icon={} + type="primary" + style={{ + right: 24, + bottom: 24, + }} /> { 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; \ No newline at end of file diff --git a/src/pages/story/index.tsx b/src/pages/story/index.tsx index 7f7ba75..304e8ec 100644 --- a/src/pages/story/index.tsx +++ b/src/pages/story/index.tsx @@ -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(false); const [open, setVisible] = useState(false); + const [authorizeModelOpen, setAuthorizeModelOpen] = useState(false); const [current, setCurrent] = useState | undefined>(undefined); const { data: listData, @@ -195,7 +197,26 @@ export const BasicList: FC = () => { > 编辑 , - , + // 增加授权操作,可以授权给其他用户 + { + e.preventDefault(); + setCurrent(item); + setAuthorizeModelOpen(true); + }} + > + 授权 + , + { + e.preventDefault(); + deleteItem(item.instanceId ?? ''); + }} + > + 删除 + , ]} > { onDone={handleDone} onSubmit={handleSubmit} /> + { + if(flag) { + run(); + } + setAuthorizeModelOpen(false); + setCurrent({}); + }} + /> ); }; diff --git a/src/pages/story/service.ts b/src/pages/story/service.ts index b62d64d..f4c99af 100644 --- a/src/pages/story/service.ts +++ b/src/pages/story/service.ts @@ -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 & Partial; +} & Partial & Partial & Partial; 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 { getResponse: true, }); } +export async function updateStoryItem(params: FormData): Promise { + return request(`/story/item`, { + method: 'PUT', + data: params, + requestType: 'form', + getResponse: true, + }); +} export async function queryStoryItem(params: ParamsType): Promise<{ data: CommonListResponse }> { return request(`/story/item/list`, { @@ -93,3 +101,10 @@ export async function fetchImage(imageInstanceId: string): Promise { getResponse: true, }); } + +export async function authorizeStoryPermission(params: {userId: string, storyInstanceId: string, permissionType: number}) { + return request('/story/permission/authorize', { + method: 'POST', + data: params, + }); +} \ No newline at end of file diff --git a/src/pages/user/login/index.tsx b/src/pages/user/login/index.tsx index 5079b0f..b3685c6 100644 --- a/src/pages/user/login/index.tsx +++ b/src/pages/user/login/index.tsx @@ -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, [UserLoginParams]>( + (params: UserLoginParams): Promise> => loginUser(params), + { + manual: true, + formatResult: (res) => res, + onSuccess: async (response: CommonResponse, 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={logo} - title="Ant Design" + title="Timeline" subTitle={intl.formatMessage({ id: 'pages.layouts.userLayout.title' })} initialValues={{ autoLogin: true, }} - actions={[ + loading={submitting} + /*actions={[ , , - ]} - onFinish={async (values) => { - await handleSubmit(values as API.LoginParams); - }} + ]}*/ + onFinish={async (values) => { + await login({ ...values, loginType: type } as UserLoginParams); + }} > - { }), }, ]} - /> + />*/} {status === 'error' && loginType === 'account' && ( { }} 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 = () => { diff --git a/src/pages/user/register/index.tsx b/src/pages/user/register/index.tsx index eb76275..fc67f27 100644 --- a/src/pages/user/register/index.tsx +++ b/src/pages/user/register/index.tsx @@ -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; + 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 = () => {

注册

- + + + + { @@ -216,8 +236,23 @@ const Register: FC = () => { + + + { pattern: /^\d{11}$/, message: '手机号格式错误!', }, - ]} + ]} */ > - + {/* { {count ? `${count} s` : '获取验证码'} - + */}