feat: 支持视频上传、预览及移动端适配
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
1. 功能增强: - 支持视频文件的上传、存储及缩略图自动生成 - 新增视频播放组件,支持在画廊和时间线中预览视频 - 引入 STOMP 协议支持 WebSocket 实时通知功能 - 增加分享页面(SSR 友好),支持通过 shareId 访问公开内容 2. 移动端优化: - 新增 BottomNav 底部导航组件,优化移动端交互体验 - 引入 useIsMobile 钩子,实现响应式布局切换 - 优化时间线卡片在小屏幕下的显示效果 3. 架构与组件: - 新增 ClientOnly 组件解决 SSR 激活不一致问题 - 新增 ResponsiveGrid 响应式网格布局组件 - 完善 Nginx 配置,增加 MinIO 对象存储代理 - 优化图片懒加载组件 TimelineImage,支持低分辨率占位图
This commit is contained in:
19
src/components/BottomNav/BottomNav.less
Normal file
19
src/components/BottomNav/BottomNav.less
Normal file
@@ -0,0 +1,19 @@
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
z-index: 100;
|
||||
|
||||
.ant-menu {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border: none;
|
||||
|
||||
.ant-menu-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/components/BottomNav/index.tsx
Normal file
32
src/components/BottomNav/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Menu } from 'antd';
|
||||
import { HomeOutlined, UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { history, useLocation } from '@umijs/max';
|
||||
import './BottomNav.less';
|
||||
|
||||
const BottomNav: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const items = [
|
||||
{ key: '/', icon: <HomeOutlined />, label: '首页' },
|
||||
{ key: '/story', icon: <ClockCircleOutlined />, label: '时间线' },
|
||||
{ key: '/profile', icon: <UserOutlined />, label: '我的' },
|
||||
];
|
||||
|
||||
const onClick = (e: any) => {
|
||||
history.push(e.key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bottom-nav">
|
||||
<Menu
|
||||
onClick={onClick}
|
||||
selectedKeys={[location.pathname]}
|
||||
mode="horizontal"
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomNav;
|
||||
21
src/components/ClientOnly/index.tsx
Normal file
21
src/components/ClientOnly/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* A component that only renders its children on the client-side.
|
||||
* This is useful for wrapping components that are not SSR-friendly.
|
||||
*/
|
||||
const ClientOnly: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!hasMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ClientOnly;
|
||||
28
src/components/Highlight/index.tsx
Normal file
28
src/components/Highlight/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
interface HighlightProps {
|
||||
text: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
const Highlight: React.FC<HighlightProps> = ({ text, keyword }) => {
|
||||
if (!keyword) return <>{text}</>;
|
||||
|
||||
const parts = text.split(new RegExp(`(${keyword})`, 'gi'));
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.toLowerCase() === keyword.toLowerCase() ? (
|
||||
<span key={index} style={{ color: 'red' }}>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Highlight;
|
||||
39
src/components/NotificationCenter/NotificationCenter.tsx
Normal file
39
src/components/NotificationCenter/NotificationCenter.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
import React from 'react';
|
||||
import { List, Button, Avatar } from 'antd';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { Notification } from '@/types';
|
||||
|
||||
const NotificationCenter: React.FC = () => {
|
||||
const { notifications, markAsRead } = useNotifications();
|
||||
|
||||
const handleMarkAllAsRead = () => {
|
||||
const unreadIds = notifications.filter(n => !n.read).map(n => n.id);
|
||||
if (unreadIds.length > 0) {
|
||||
markAsRead(unreadIds);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button onClick={handleMarkAllAsRead} style={{ marginBottom: 16 }} disabled={notifications.filter(n => !n.read).length === 0}>
|
||||
全部标记为已读
|
||||
</Button>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={notifications}
|
||||
renderItem={(item: Notification) => (
|
||||
<List.Item style={{ opacity: item.read ? 0.5 : 1 }}>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={item.senderAvatar} />}
|
||||
title={<a href="#">{item.senderName}</a>}
|
||||
description={item.content}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
33
src/components/ResponsiveGrid/index.tsx
Normal file
33
src/components/ResponsiveGrid/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Row, Col } from 'antd';
|
||||
|
||||
interface ResponsiveGridProps {
|
||||
children: React.ReactNode;
|
||||
cols?: { [key: string]: number };
|
||||
}
|
||||
|
||||
const ResponsiveGrid: React.FC<ResponsiveGridProps> = ({ children, cols = { xs: 1, sm: 2, md: 3, lg: 4, xl: 6 } }) => {
|
||||
// Helper to calculate span based on columns, ensuring it fits in 24 grid
|
||||
const getSpan = (colCount: number) => {
|
||||
const span = Math.floor(24 / Math.max(1, colCount));
|
||||
return span;
|
||||
};
|
||||
|
||||
const responsiveProps = {
|
||||
xs: getSpan(cols.xs || 1),
|
||||
sm: getSpan(cols.sm || 2),
|
||||
md: getSpan(cols.md || 3),
|
||||
lg: getSpan(cols.lg || 4),
|
||||
xl: getSpan(cols.xl || 6), // Changed default to 6 for clean division (24/6=4)
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
{React.Children.map(children, child => (
|
||||
<Col {...responsiveProps}>{child}</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsiveGrid;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { QuestionCircleOutlined, BellOutlined } from '@ant-design/icons';
|
||||
import { SelectLang as UmiSelectLang } from '@umijs/max';
|
||||
import { Popover, Badge, Space } from 'antd';
|
||||
import NotificationCenter from '@/components/NotificationCenter/NotificationCenter';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
export type SiderTheme = 'light' | 'dark';
|
||||
|
||||
@@ -22,3 +25,21 @@ export const Question = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Notifications = () => {
|
||||
const { unreadCount } = useNotifications();
|
||||
|
||||
const notificationContent = (
|
||||
<div style={{ width: 300 }}>
|
||||
<NotificationCenter />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={notificationContent} title="通知" trigger="click">
|
||||
<Badge count={unreadCount}>
|
||||
<BellOutlined style={{ fontSize: 20, cursor: 'pointer' }} />
|
||||
</Badge>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
26
src/components/TimelineImage/index.less
Normal file
26
src/components/TimelineImage/index.less
Normal file
@@ -0,0 +1,26 @@
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f0f2f5; // Ant Design 的背景色
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,67 +1,60 @@
|
||||
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';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import styles from './index.less';
|
||||
|
||||
interface ImageItem {
|
||||
instanceId: string;
|
||||
imageName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
src?: string;
|
||||
title: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
fallback?: string;
|
||||
imageInstanceId?: string;
|
||||
imageList?: ImageItem[];
|
||||
currentIndex?: number;
|
||||
interface TimelineImageProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
// 占位符, 通常是一个低分辨率的图片
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const TimelineImage: React.FC<Props> = (props) => {
|
||||
const {
|
||||
src,
|
||||
title,
|
||||
imageInstanceId,
|
||||
fallback,
|
||||
width = 200,
|
||||
height = 200,
|
||||
imageList = [],
|
||||
currentIndex = 0,
|
||||
style,
|
||||
} = props;
|
||||
const TimelineImage: React.FC<TimelineImageProps> = ({ src, alt, className, style, placeholder }) => {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
/* const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
|
||||
const [previewVisible, setPreviewVisible] = useState(false); */
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = src;
|
||||
observer.unobserve(imgRef.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 } // 元素可见 10% 时触发
|
||||
);
|
||||
|
||||
// 构建预览列表
|
||||
imageList.map((item) => ({
|
||||
src: item.instanceId,
|
||||
title: item.imageName,
|
||||
}));
|
||||
if (imgRef.current) {
|
||||
observer.observe(imgRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (imgRef.current) {
|
||||
observer.unobserve(imgRef.current);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tl-image-container" style={{ width, height }}>
|
||||
<Image
|
||||
loading="lazy"
|
||||
src={src ?? `/file/image-low-res/${imageInstanceId}?Authorization=${getAuthization()}`}
|
||||
preview={{
|
||||
src: `/file/image/${imageInstanceId}?Authorization=${getAuthization()}`,
|
||||
}}
|
||||
height={height}
|
||||
width={width}
|
||||
style={style}
|
||||
alt={title}
|
||||
fallback={
|
||||
fallback ??
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v////y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEeg......'
|
||||
}
|
||||
<div className={classNames(styles.imageContainer, className)} style={style}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={alt}
|
||||
onLoad={handleImageLoad}
|
||||
src={placeholder} // 初始显示占位符
|
||||
className={classNames(styles.image, { [styles.loaded]: isLoaded })}
|
||||
/>
|
||||
{!isLoaded && <div className={styles.placeholder}></div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineImage;
|
||||
export default TimelineImage;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Question, SelectLang } from './RightContent';
|
||||
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
|
||||
|
||||
export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };
|
||||
export { default as ClientOnly } from './ClientOnly';
|
||||
|
||||
// 导出Hooks
|
||||
export { default as useFetchImageUrl } from './Hooks/useFetchImageUrl';
|
||||
|
||||
Reference in New Issue
Block a user