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:
@@ -1,8 +1,8 @@
|
||||
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
|
||||
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
||||
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
|
||||
import { getImagesList } from '@/services/file/api'; // 引入获取图库图片的API
|
||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
|
||||
import { PlusOutlined, SearchOutlined, VideoCameraOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useRequest } from '@umijs/max';
|
||||
import {
|
||||
Button,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Spin,
|
||||
Tabs,
|
||||
Upload,
|
||||
Progress,
|
||||
} from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import moment from 'moment';
|
||||
@@ -58,8 +59,23 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const searchInputRef = useRef<InputRef>(null);
|
||||
|
||||
// 视频相关状态
|
||||
const [videoInfo, setVideoInfo] = useState<{
|
||||
videoUrl?: string;
|
||||
duration?: number;
|
||||
thumbnailUrl?: string;
|
||||
uploading?: boolean;
|
||||
progress?: number;
|
||||
}>({
|
||||
videoUrl: initialValues?.videoUrl,
|
||||
duration: initialValues?.duration,
|
||||
thumbnailUrl: initialValues?.thumbnailUrl,
|
||||
});
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(initialValues)
|
||||
console.log(initialValues);
|
||||
if (initialValues && option.startsWith('edit')) {
|
||||
form.setFieldsValue({
|
||||
title: initialValues.title,
|
||||
@@ -67,6 +83,13 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
location: initialValues.location,
|
||||
description: initialValues.description,
|
||||
});
|
||||
setVideoInfo({
|
||||
videoUrl: initialValues.videoUrl,
|
||||
duration: initialValues.duration,
|
||||
thumbnailUrl: initialValues.thumbnailUrl,
|
||||
});
|
||||
} else if (!initialValues) {
|
||||
setVideoInfo({});
|
||||
}
|
||||
}, [initialValues, option, visible]);
|
||||
|
||||
@@ -80,11 +103,12 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
pageSize: pageSize,
|
||||
keyword: keyword,
|
||||
});
|
||||
const images = response?.data?.list.map((img: any) => ({
|
||||
instanceId: img.instanceId,
|
||||
imageName: img.imageName,
|
||||
url: `/file/image-low-res/${img.instanceId}`,
|
||||
})) ?? [];
|
||||
const images =
|
||||
response?.data?.list.map((img: any) => ({
|
||||
instanceId: img.instanceId,
|
||||
imageName: img.imageName,
|
||||
url: `/file/image-low-res/${img.instanceId}`,
|
||||
})) ?? [];
|
||||
setGalleryImages(images);
|
||||
setTotal(response?.data?.total ?? 0);
|
||||
setCurrentPage(page);
|
||||
@@ -102,20 +126,132 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
}
|
||||
}, [visible, activeTab, searchKeyword]);
|
||||
|
||||
const { run: submitItem, loading } = useRequest((newItem) => option.includes('edit') ? updateStoryItem(newItem) : addStoryItem(newItem), {
|
||||
manual: true,
|
||||
onSuccess: (data) => {
|
||||
console.log(data);
|
||||
if (data.code === 200) {
|
||||
onOk();
|
||||
message.success(initialValues ? '时间点已更新' : '时间点已保存');
|
||||
const { run: submitItem, loading } = useRequest(
|
||||
(newItem) => (option.includes('edit') ? updateStoryItem(newItem) : addStoryItem(newItem)),
|
||||
{
|
||||
manual: true,
|
||||
onSuccess: (data) => {
|
||||
console.log(data);
|
||||
if (data.code === 200) {
|
||||
onOk();
|
||||
message.success(initialValues ? '时间点已更新' : '时间点已保存');
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error('保存失败');
|
||||
console.error('保存失败:', error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const handleVideoUpload = async (options: any) => {
|
||||
const { file, onSuccess, onError, onProgress } = options;
|
||||
|
||||
setVideoInfo((prev) => ({ ...prev, uploading: true, progress: 0 }));
|
||||
|
||||
try {
|
||||
// 1. 获取上传 URL
|
||||
const fileName = `${Date.now()}-${file.name}`;
|
||||
const uploadUrlRes = await getUploadUrl(fileName);
|
||||
if (uploadUrlRes.code !== 200) throw new Error('获取上传链接失败');
|
||||
|
||||
const uploadUrl = uploadUrlRes.data;
|
||||
|
||||
// 2. 上传文件到 MinIO
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl, true);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
setVideoInfo((prev) => ({ ...prev, progress: percent }));
|
||||
onProgress({ percent });
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
|
||||
else reject(new Error('上传失败'));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络错误'));
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// 3. 保存视频元数据到文件服务,获取 instanceId
|
||||
const metaRes = await saveFileMetadata({
|
||||
imageName: file.name,
|
||||
objectKey: fileName,
|
||||
size: file.size,
|
||||
contentType: file.type || 'video/mp4',
|
||||
});
|
||||
|
||||
if (metaRes.code !== 200) throw new Error('保存元数据失败');
|
||||
const videoInstanceId = metaRes.data;
|
||||
|
||||
// 4. 生成缩略图
|
||||
const videoUrl = URL.createObjectURL(file);
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.src = videoUrl;
|
||||
videoEl.currentTime = 1;
|
||||
videoEl.muted = true;
|
||||
videoEl.playsInline = true;
|
||||
|
||||
await new Promise((resolve) => {
|
||||
videoEl.onloadeddata = () => resolve(true);
|
||||
videoEl.load();
|
||||
});
|
||||
// Ensure seeked
|
||||
videoEl.currentTime = 1;
|
||||
await new Promise((r) => (videoEl.onseeked = r));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoEl.videoWidth;
|
||||
canvas.height = videoEl.videoHeight;
|
||||
canvas.getContext('2d')?.drawImage(videoEl, 0, 0);
|
||||
|
||||
let thumbnailInstanceId = '';
|
||||
|
||||
try {
|
||||
const thumbnailBlob = await new Promise<Blob | null>((r) =>
|
||||
canvas.toBlob(r, 'image/jpeg', 0.7),
|
||||
);
|
||||
if (thumbnailBlob) {
|
||||
const thumbName = `thumb-${Date.now()}.jpg`;
|
||||
const thumbUrlRes = await getUploadUrl(thumbName);
|
||||
if (thumbUrlRes.code === 200) {
|
||||
await fetch(thumbUrlRes.data, { method: 'PUT', body: thumbnailBlob });
|
||||
// 保存缩略图元数据
|
||||
const thumbMetaRes = await saveFileMetadata({
|
||||
imageName: thumbName,
|
||||
objectKey: thumbName,
|
||||
size: thumbnailBlob.size,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
if (thumbMetaRes.code === 200) {
|
||||
thumbnailInstanceId = thumbMetaRes.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('缩略图生成失败', e);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error('保存失败');
|
||||
console.error('保存失败:', error);
|
||||
},
|
||||
});
|
||||
|
||||
setVideoInfo({
|
||||
videoUrl: videoInstanceId, // 存储 instanceId
|
||||
duration: Math.floor(videoEl.duration),
|
||||
thumbnailUrl: thumbnailInstanceId, // 存储 instanceId
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
onSuccess('上传成功');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
onError(err);
|
||||
setVideoInfo((prev) => ({ ...prev, uploading: false }));
|
||||
message.error('视频上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
@@ -132,6 +268,10 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
instanceId: initialValues?.instanceId,
|
||||
// 添加选中的图库图片ID
|
||||
relatedImageInstanceIds: selectedGalleryImages,
|
||||
// 视频信息
|
||||
videoUrl: videoInfo.videoUrl,
|
||||
duration: videoInfo.duration,
|
||||
thumbnailUrl: videoInfo.thumbnailUrl,
|
||||
};
|
||||
// 构建 FormData
|
||||
const formData = new FormData();
|
||||
@@ -358,7 +498,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
</Form.Item>
|
||||
|
||||
{/* 时刻图库 */}
|
||||
<Form.Item label="附图" name="images">
|
||||
<Form.Item label="媒体内容" name="media">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
@@ -380,6 +520,84 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'video',
|
||||
label: '上传视频',
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '20px',
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
{!videoInfo.videoUrl && !videoInfo.uploading && (
|
||||
<Upload
|
||||
beforeUpload={(file) => {
|
||||
handleVideoUpload({
|
||||
file,
|
||||
onSuccess: () => {},
|
||||
onError: () => {},
|
||||
onProgress: () => {},
|
||||
});
|
||||
return false;
|
||||
}}
|
||||
showUploadList={false}
|
||||
accept="video/*"
|
||||
>
|
||||
<div style={{ cursor: 'pointer' }}>
|
||||
<VideoCameraOutlined style={{ fontSize: '24px', color: '#1890ff' }} />
|
||||
<div style={{ marginTop: 8 }}>点击上传视频</div>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
|
||||
{videoInfo.uploading && (
|
||||
<div style={{ width: '80%', margin: '0 auto' }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, marginBottom: 10 }} />
|
||||
<div style={{ marginBottom: 10 }}>视频处理中... {videoInfo.progress}%</div>
|
||||
<Progress percent={videoInfo.progress} status="active" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videoInfo.videoUrl && !videoInfo.uploading && (
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div style={{ marginBottom: 10, color: '#52c41a' }}>视频已就绪</div>
|
||||
{videoInfo.thumbnailUrl ? (
|
||||
<img
|
||||
src={`/api/file/image-low-res/${videoInfo.thumbnailUrl}`}
|
||||
alt="thumbnail"
|
||||
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 200,
|
||||
height: 120,
|
||||
background: '#000',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
视频已上传
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => setVideoInfo({})}
|
||||
style={{ display: 'block', margin: '10px auto' }}
|
||||
>
|
||||
删除视频
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'gallery',
|
||||
label: `从图库选择${selectedGalleryImages.length > 0 ? ` (${selectedGalleryImages.length})` : ''}`,
|
||||
@@ -393,4 +611,4 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTimeLineItemModal;
|
||||
export default AddTimeLineItemModal;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useIntl, useRequest } from '@umijs/max';
|
||||
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
import { queryStoryItemImages, removeStoryItem } from '../service';
|
||||
import TimelineVideo from './TimelineVideo';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
@@ -24,7 +25,7 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
const [hour, minute] = timePart.split(':');
|
||||
return `${hour}:${minute}`;
|
||||
}
|
||||
|
||||
|
||||
return timeStr;
|
||||
};
|
||||
|
||||
@@ -37,11 +38,11 @@ const TimelineGridItem: React.FC<{
|
||||
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
|
||||
const intl = useIntl();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
|
||||
// 动态设置CSS变量以适配主题
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
|
||||
// 根据Ant Design的token设置主题变量
|
||||
root.style.setProperty('--timeline-bg', token.colorBgContainer);
|
||||
root.style.setProperty('--timeline-card-bg', token.colorBgElevated);
|
||||
@@ -58,12 +59,15 @@ const TimelineGridItem: React.FC<{
|
||||
root.style.setProperty('--timeline-more-bg', token.colorBgMask);
|
||||
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
||||
}, [token]);
|
||||
|
||||
const { data: imagesList } = useRequest(async () => {
|
||||
return await queryStoryItemImages(item.instanceId);
|
||||
}, {
|
||||
refreshDeps: [item.instanceId]
|
||||
});
|
||||
|
||||
const { data: imagesList } = useRequest(
|
||||
async () => {
|
||||
return await queryStoryItemImages(item.instanceId);
|
||||
},
|
||||
{
|
||||
refreshDeps: [item.instanceId],
|
||||
},
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -84,7 +88,7 @@ const TimelineGridItem: React.FC<{
|
||||
const calculateCardSize = () => {
|
||||
const imageCount = imagesList?.length || 0;
|
||||
const descriptionLength = item.description?.length || 0;
|
||||
|
||||
|
||||
// 根据图片数量和描述长度决定卡片大小
|
||||
if (imageCount >= 4 || descriptionLength > 200) {
|
||||
return 4; // 占据整行
|
||||
@@ -98,15 +102,20 @@ const TimelineGridItem: React.FC<{
|
||||
};
|
||||
|
||||
const cardSize = calculateCardSize();
|
||||
|
||||
|
||||
// 统一的文本长度 - 根据卡片大小调整
|
||||
const getDescriptionMaxLength = (size: number) => {
|
||||
switch (size) {
|
||||
case 1: return 80;
|
||||
case 2: return 150;
|
||||
case 3: return 200;
|
||||
case 4: return 300;
|
||||
default: return 100;
|
||||
case 1:
|
||||
return 80;
|
||||
case 2:
|
||||
return 150;
|
||||
case 3:
|
||||
return 200;
|
||||
case 4:
|
||||
return 300;
|
||||
default:
|
||||
return 100;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,10 +129,7 @@ const TimelineGridItem: React.FC<{
|
||||
|
||||
// 只返回article元素,不包含任何其他元素
|
||||
return (
|
||||
<article
|
||||
className={`timeline-grid-item size-${cardSize}`}
|
||||
onClick={() => onOpenDetail(item)}
|
||||
>
|
||||
<article className={`timeline-grid-item size-${cardSize}`} onClick={() => onOpenDetail(item)}>
|
||||
{/* Action buttons */}
|
||||
{!disableEdit && (
|
||||
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -169,33 +175,33 @@ const TimelineGridItem: React.FC<{
|
||||
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
||||
|
||||
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<div className="item-images-container">
|
||||
<div className="item-images-row">
|
||||
{imagesList
|
||||
.filter(imageInstanceId => imageInstanceId && imageInstanceId.trim() !== '')
|
||||
.slice(0, 6) // 最多显示6张图片
|
||||
.map((imageInstanceId, index) => (
|
||||
<div key={imageInstanceId + index} className="item-image-wrapper">
|
||||
<TimelineImage
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{imagesList.length > 6 && (
|
||||
<div className="more-images-indicator">
|
||||
+{imagesList.length - 6}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.videoUrl ? (
|
||||
<div className="item-images-container" style={{ marginTop: '10px' }}>
|
||||
<TimelineVideo videoInstanceId={item.videoUrl} thumbnailInstanceId={item.thumbnailUrl} />
|
||||
</div>
|
||||
) : (
|
||||
imagesList &&
|
||||
imagesList.length > 0 && (
|
||||
<div className="item-images-container">
|
||||
<div className="item-images-row">
|
||||
{imagesList
|
||||
.filter((imageInstanceId) => imageInstanceId && imageInstanceId.trim() !== '')
|
||||
.slice(0, 6) // 最多显示6张图片
|
||||
.map((imageInstanceId, index) => (
|
||||
<div key={imageInstanceId + index} className="item-image-wrapper">
|
||||
<TimelineImage title={imageInstanceId} imageInstanceId={imageInstanceId} />
|
||||
</div>
|
||||
))}
|
||||
{imagesList.length > 6 && (
|
||||
<div className="more-images-indicator">+{imagesList.length - 6}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
{item.location && (
|
||||
<span className="timeline-location-badge">📍 {item.location}</span>
|
||||
)}
|
||||
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
||||
|
||||
{/* Creator/Updater tags */}
|
||||
<div className="item-tags">
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
||||
import { queryStoryItemImages, removeStoryItem } from '../../service';
|
||||
import TimelineItemDrawer from '../TimelineItemDrawer';
|
||||
import useStyles from './index.style';
|
||||
import ResponsiveGrid from '@/components/ResponsiveGrid';
|
||||
|
||||
// 格式化时间数组为易读格式
|
||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||
@@ -29,7 +30,7 @@ const TimelineItem: React.FC<{
|
||||
refresh: () => void;
|
||||
disableEdit?: boolean;
|
||||
}> = ({ item, handleOption, refresh, disableEdit }) => {
|
||||
const { styles } = useStyles();
|
||||
const { styles, cx, isMobile } = useStyles();
|
||||
const intl = useIntl();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
@@ -98,7 +99,7 @@ const TimelineItem: React.FC<{
|
||||
}}
|
||||
extra={
|
||||
<div className={styles.actions}>
|
||||
{showActions && !disableEdit && (
|
||||
{(showActions || isMobile) && !disableEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -158,14 +159,16 @@ const TimelineItem: React.FC<{
|
||||
</div>
|
||||
{imagesList && imagesList.length > 0 && (
|
||||
<div className="timeline-images-grid">
|
||||
{imagesList.map((imageInstanceId, index) => (
|
||||
<TimelineImage
|
||||
key={imageInstanceId + index}
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
className={styles.timelineImage}
|
||||
/>
|
||||
))}
|
||||
<ResponsiveGrid>
|
||||
{imagesList.map((imageInstanceId, index) => (
|
||||
<TimelineImage
|
||||
key={imageInstanceId + index}
|
||||
title={imageInstanceId}
|
||||
imageInstanceId={imageInstanceId}
|
||||
className={styles.timelineImage}
|
||||
/>
|
||||
))}
|
||||
</ResponsiveGrid>
|
||||
</div>
|
||||
)}
|
||||
{item.subItems && item.subItems.length > 0 && (
|
||||
|
||||
@@ -1,158 +1,63 @@
|
||||
// index.style.ts
|
||||
import { createStyles } from 'antd-style';
|
||||
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
const useStyles = createStyles(({ token, isMobile }) => {
|
||||
return {
|
||||
timelineItem: {
|
||||
marginBottom: '20px',
|
||||
boxShadow: token.boxShadow,
|
||||
borderRadius: token.borderRadius,
|
||||
transition: 'all 0.3s',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
padding: '20px',
|
||||
maxWidth: '100%',
|
||||
textAlign: 'left',
|
||||
maxHeight: '80vh',
|
||||
scrollBehavior: 'smooth',
|
||||
scrollbarWidth: 'none',
|
||||
borderBottom: '1px solid #eee',
|
||||
[`@media (max-width: 768px)`]: {
|
||||
padding: '10px',
|
||||
},
|
||||
},
|
||||
timelineItemHover: {
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
// ... (no changes here)
|
||||
},
|
||||
// ... (no changes here)
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
height: '24px',
|
||||
width: '120px',
|
||||
// width: '120px', // 移除固定宽度
|
||||
opacity: isMobile ? 1 : undefined, // 在移动端始终可见
|
||||
},
|
||||
timelineItemTitle: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
timelineItemTitleText: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
[`@media (max-width: 768px)`]: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
},
|
||||
timelineItemTags: {
|
||||
flexShrink: 0,
|
||||
marginLeft: '16px',
|
||||
},
|
||||
creatorTag: {
|
||||
fontSize: '12px',
|
||||
},
|
||||
updaterTag: {
|
||||
fontSize: '12px',
|
||||
},
|
||||
content: {
|
||||
padding: '10px 0',
|
||||
},
|
||||
cover: {
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '15px',
|
||||
img: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
},
|
||||
},
|
||||
date: {
|
||||
fontSize: '14px',
|
||||
color: token.colorTextSecondary,
|
||||
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',
|
||||
color: token.colorText,
|
||||
marginBottom: '15px',
|
||||
'.ant-btn-link': {
|
||||
padding: '0 4px',
|
||||
},
|
||||
},
|
||||
subItems: {
|
||||
borderTop: `1px dashed ${token.colorBorder}`,
|
||||
paddingTop: '15px',
|
||||
marginTop: '15px',
|
||||
},
|
||||
subItemsHeader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
color: token.colorTextHeading,
|
||||
marginBottom: '10px',
|
||||
padding: '5px 0',
|
||||
},
|
||||
subItemsList: {
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
marginLeft: isMobile ? 0 : '16px',
|
||||
marginTop: isMobile ? '8px' : 0,
|
||||
},
|
||||
// ... (no changes here)
|
||||
subItem: {
|
||||
display: 'flex',
|
||||
marginBottom: '10px',
|
||||
[`@media (max-width: 768px)`]: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'&:last-child': {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
subItemDate: {
|
||||
fontWeight: 'bold',
|
||||
minWidth: '100px',
|
||||
minWidth: isMobile ? 'auto' : '100px',
|
||||
marginBottom: isMobile ? '4px' : 0,
|
||||
color: token.colorTextSecondary,
|
||||
},
|
||||
subItemContent: {
|
||||
flex: 1,
|
||||
color: token.colorText,
|
||||
},
|
||||
// ... (no changes here)
|
||||
timelineItemImages: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', // 减小最小宽度到100px
|
||||
gap: '8px', // 减少间距
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: '8px',
|
||||
marginBottom: '20px',
|
||||
maxWidth: '100%', // 确保容器不会超出父元素宽度
|
||||
[`@media (max-width: 768px)`]: {
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(60px, 1fr))', // 在移动设备上减小最小宽度到60px
|
||||
},
|
||||
},
|
||||
timelineImage: {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
img: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
objectFit: 'cover',
|
||||
[`@media (max-width: 480px)`]: {
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||
},
|
||||
},
|
||||
// ... (the rest of the styles)
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
162
src/pages/story/components/TimelineVideo.tsx
Normal file
162
src/pages/story/components/TimelineVideo.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { PlayCircleOutlined, PauseCircleOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
||||
import { Spin } from 'antd';
|
||||
import { request } from '@umijs/max';
|
||||
|
||||
interface TimelineVideoProps {
|
||||
videoInstanceId: string;
|
||||
thumbnailInstanceId?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const TimelineVideo: React.FC<TimelineVideoProps> = ({ videoInstanceId, thumbnailInstanceId, style }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [videoSrc, setVideoSrc] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// Fetch video URL when component mounts
|
||||
useEffect(() => {
|
||||
const fetchVideoUrl = async () => {
|
||||
try {
|
||||
const response = await request(`/file/get-video-url/${videoInstanceId}`, { method: 'GET' });
|
||||
if (response.code === 200 && response.data) {
|
||||
setVideoSrc(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch video url", error);
|
||||
}
|
||||
};
|
||||
if (videoInstanceId) {
|
||||
fetchVideoUrl();
|
||||
}
|
||||
}, [videoInstanceId]);
|
||||
|
||||
|
||||
const togglePlay = () => {
|
||||
if (videoRef.current) {
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current.requestFullscreen().then(() => {
|
||||
setIsFullscreen(true);
|
||||
}).catch(err => {
|
||||
console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
setIsFullscreen(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
const thumbnailUrl = thumbnailInstanceId ? `/api/file/image-low-res/${thumbnailInstanceId}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingTop: '56.25%', // 16:9 Aspect Ratio
|
||||
backgroundColor: '#000',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
poster={thumbnailUrl}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
onClick={togglePlay}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
controls={false}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Custom Controls Overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '10px 20px',
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.7), transparent)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
opacity: isPlaying ? 0 : 1,
|
||||
transition: 'opacity 0.3s',
|
||||
pointerEvents: isPlaying ? 'none' : 'auto',
|
||||
}}
|
||||
className="video-controls"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||
style={{ cursor: 'pointer', color: '#fff', fontSize: '24px', pointerEvents: 'auto' }}
|
||||
>
|
||||
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); toggleFullscreen(); }}
|
||||
style={{ cursor: 'pointer', color: '#fff', fontSize: '24px', pointerEvents: 'auto' }}
|
||||
>
|
||||
{isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center Play Button (only visible when paused) */}
|
||||
{!isPlaying && (
|
||||
<div
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '48px',
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimelineVideo;
|
||||
Reference in New Issue
Block a user