Files
timeline-frontend/src/pages/story/components/TimelineItem/TimelineItem.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

216 lines
7.6 KiB
TypeScript

// TimelineItem.tsx
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, Tag, Space } from 'antd';
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 => {
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;
refresh: () => void;
disableEdit?: boolean;
}> = ({ item, handleOption, refresh, disableEdit }) => {
const { styles, cx, isMobile } = useStyles();
const intl = useIntl();
const [expanded, setExpanded] = useState(false);
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 {
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' }));
}
};
const toggleDescription = () => {
setExpanded(!expanded);
};
const toggleSubItems = () => {
setSubItemsExpanded(!subItemsExpanded);
};
const displayedDescription = expanded
? item.description
: item.description?.substring(0, 100) +
(item.description && item.description.length > 100 ? '...' : '');
return (
<Card
className={`${styles.timelineItem} ${hovered ? styles.timelineItemHover : ''}`}
title={
<div className={styles.timelineItemTitle}>
<div className={styles.timelineItemTitleText}>{item.title}</div>
<Space className={styles.timelineItemTags}>
{item.createName && (
<Tag color="blue" className={styles.creatorTag}>
: {item.createName}
</Tag>
)}
{item.updateName && item.updateName !== item.createName && (
<Tag color="green" className={styles.updaterTag}>
: {item.updateName}
</Tag>
)}
</Space>
</div>
}
onMouseEnter={() => {
setShowActions(true);
setHovered(true);
}}
onMouseLeave={() => {
setShowActions(false);
setHovered(false);
}}
extra={
<div className={styles.actions}>
{(showActions || isMobile) && !disableEdit && (
<>
<Button
type="text"
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"
icon={<DeleteOutlined />}
danger
onClick={(e) => e.stopPropagation()}
aria-label={intl.formatMessage({ id: 'story.delete' })}
/>
</Popconfirm>
</>
)}
</div>
}
hoverable
>
<div className={styles.content}>
<div className={styles.date} onClick={() => setOpenDetail(true)}>
<Space size="small" className={styles.dateInfo}>
<span className="timeline-date-badge">{formatTimeArray(item.storyItemTime)}</span>
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
</Space>
</div>
<div className={styles.description} onClick={() => setOpenDetail(true)}>
{displayedDescription}
{item.description && item.description.length > 100 && (
<Button
type="link"
onClick={(e) => {
e.stopPropagation();
toggleDescription();
}}
>
{expanded
? intl.formatMessage({ id: 'story.showLess' })
: intl.formatMessage({ id: 'story.showMore' })}
</Button>
)}
</div>
{imagesList && imagesList.length > 0 && (
<div className="timeline-images-grid">
<ResponsiveGrid>
{imagesList.map((imageInstanceId, index) => (
<TimelineImage
key={imageInstanceId + index}
title={imageInstanceId}
imageInstanceId={imageInstanceId}
className={styles.timelineImage}
/>
))}
</ResponsiveGrid>
</div>
)}
{item.subItems && item.subItems.length > 0 && (
<div className={styles.subItems}>
<div
className={styles.subItemsHeader}
onClick={(e) => {
e.stopPropagation();
toggleSubItems();
}}
>
<span>
{intl.formatMessage({ id: 'story.subItems' })} ({item.subItems.length})
</span>
{subItemsExpanded ? <UpOutlined /> : <DownOutlined />}
</div>
{subItemsExpanded && (
<div className={styles.subItemsList}>
{item.subItems.map((subItem) => (
<div key={subItem.id} className={styles.subItem}>
<div className={styles.subItemDate}>
{formatTimeArray(item.storyItemTime)} {item.location ? `创建于${item.location}` : ''}
</div>
<div className={styles.subItemContent}>{subItem.description}</div>
</div>
))}
</div>
)}
</div>
)}
</div>
<TimelineItemDrawer
storyItem={item}
open={openDetail}
setOpen={setOpenDetail}
handleDelete={handleDelete}
disableEdit={disableEdit}
handOption={handleOption}
/>
</Card>
);
};
export default TimelineItem;