Files
timeline-frontend/src/pages/story/components/TimelineGridItem.tsx
jianghao cd752d97d8
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
feat: 支持视频上传、预览及移动端适配
1. 功能增强:
- 支持视频文件的上传、存储及缩略图自动生成
- 新增视频播放组件,支持在画廊和时间线中预览视频
- 引入 STOMP 协议支持 WebSocket 实时通知功能
- 增加分享页面(SSR 友好),支持通过 shareId 访问公开内容

2. 移动端优化:
- 新增 BottomNav 底部导航组件,优化移动端交互体验
- 引入 useIsMobile 钩子,实现响应式布局切换
- 优化时间线卡片在小屏幕下的显示效果

3. 架构与组件:
- 新增 ClientOnly 组件解决 SSR 激活不一致问题
- 新增 ResponsiveGrid 响应式网格布局组件
- 完善 Nginx 配置,增加 MinIO 对象存储代理
- 优化图片懒加载组件 TimelineImage,支持低分辨率占位图
2026-02-12 16:55:05 +08:00

224 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TimelineGridItem.tsx - Grid card layout for timeline items
import TimelineImage from '@/components/TimelineImage';
import { StoryItem } from '@/pages/story/data';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
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 => {
if (!time) return '';
// 如果是数组格式 [2025, 12, 23, 8, 55, 39]
if (Array.isArray(time)) {
const [, , , hour, minute] = time;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
// 如果已经是字符串格式,提取时间部分
const timeStr = String(time);
const timePart = timeStr.split(' ')[1];
if (timePart) {
const [hour, minute] = timePart.split(':');
return `${hour}:${minute}`;
}
return timeStr;
};
const TimelineGridItem: React.FC<{
item: StoryItem;
handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
onOpenDetail: (item: StoryItem) => void;
refresh: () => void;
disableEdit?: boolean;
}> = ({ 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);
root.style.setProperty('--timeline-card-border', token.colorBorder);
root.style.setProperty('--timeline-card-shadow', `0 2px 8px ${token.colorBgMask}`);
root.style.setProperty('--timeline-text-primary', token.colorText);
root.style.setProperty('--timeline-text-secondary', token.colorTextSecondary);
root.style.setProperty('--timeline-text-tertiary', token.colorTextTertiary);
root.style.setProperty('--timeline-header-color', token.colorPrimary);
root.style.setProperty('--timeline-location-bg', `${token.colorSuccess}1A`); // 10% opacity
root.style.setProperty('--timeline-location-border', `${token.colorSuccess}4D`); // 30% opacity
root.style.setProperty('--timeline-location-color', token.colorSuccess);
root.style.setProperty('--timeline-image-border', token.colorBorder);
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 handleDelete = async () => {
try {
if (!item.instanceId) return;
const response = await removeStoryItem(item.instanceId);
if (response.code === 200) {
message.success(intl.formatMessage({ id: 'story.deleteSuccess' }));
refresh();
} else {
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
}
} catch (error) {
message.error(intl.formatMessage({ id: 'story.deleteFailed' }));
}
};
// 动态计算卡片大小1-4格
const calculateCardSize = () => {
const imageCount = imagesList?.length || 0;
const descriptionLength = item.description?.length || 0;
// 根据图片数量和描述长度决定卡片大小
if (imageCount >= 4 || descriptionLength > 200) {
return 4; // 占据整行
} else if (imageCount >= 2 || descriptionLength > 100) {
return 2; // 占据2格
} else if (imageCount >= 1 && descriptionLength > 50) {
return 2; // 有图片且描述较长占据2格
} else {
return 1; // 默认占据1格
}
};
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;
}
};
const descriptionMaxLength = getDescriptionMaxLength(cardSize);
// 截断描述文本
const truncateText = (text: string | undefined, maxLength: number) => {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};
// 只返回article元素不包含任何其他元素
return (
<article className={`timeline-grid-item size-${cardSize}`} onClick={() => onOpenDetail(item)}>
{/* Action buttons */}
{!disableEdit && (
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
handleOption(item, 'edit');
}}
aria-label={intl.formatMessage({ id: 'story.edit' })}
/>
<Popconfirm
title={intl.formatMessage({ id: 'story.deleteConfirm' })}
description={intl.formatMessage({ id: 'story.deleteConfirmDescription' })}
onConfirm={(e) => {
e?.stopPropagation();
handleDelete();
}}
okText={intl.formatMessage({ id: 'story.yes' })}
cancelText={intl.formatMessage({ id: 'story.no' })}
>
<Button
type="text"
size="small"
icon={<DeleteOutlined />}
danger
onClick={(e) => e.stopPropagation()}
aria-label={intl.formatMessage({ id: 'story.delete' })}
/>
</Popconfirm>
</div>
)}
{/* Time header */}
<h3>{formatTimeArray(item.storyItemTime)}</h3>
{/* Title */}
<div className="item-title">{item.title}</div>
{/* Description */}
<p>{truncateText(item.description, descriptionMaxLength)}</p>
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
{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>}
{/* Creator/Updater tags */}
<div className="item-tags">
{item.createName && (
<Tag color="blue" style={{ fontSize: '11px', padding: '2px 8px' }}>
{item.createName}
</Tag>
)}
{item.updateName && item.updateName !== item.createName && (
<Tag color="green" style={{ fontSize: '11px', padding: '2px 8px' }}>
{item.updateName}
</Tag>
)}
</div>
</article>
);
};
export default TimelineGridItem;