feat: 支持视频上传、预览及移动端适配
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:
2026-02-12 16:55:05 +08:00
parent 336208b7ce
commit cd752d97d8
39 changed files with 1729 additions and 537 deletions

View 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;
}
}
}

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

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

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

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

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

View File

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

View 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;
}

View File

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

View File

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