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

@@ -1,11 +1,12 @@
// src/pages/gallery/components/GalleryTable.tsx
import { ImageItem } from '@/pages/gallery/typings';
import { formatBytes } from '@/utils/timelineUtils';
import { getAuthization } from '@/utils/userUtils';
import type { ProColumns } from '@ant-design/pro-components';
import { ProTable } from '@ant-design/pro-components';
import { FC } from 'react';
import '../index.css';
import { getAuthization } from '@/utils/userUtils';
import { PlayCircleOutlined } from '@ant-design/icons';
interface GalleryTableProps {
imageList: ImageItem[];
@@ -39,19 +40,32 @@ const GalleryTable: FC<GalleryTableProps> = ({
title: '图片',
dataIndex: 'instanceId',
width: 120,
render: (_, record) => (
<div
style={{
width: 100,
height: 100,
backgroundImage: `url(/file/image-low-res/${record.instanceId}?Authorization=${getAuthization()})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
borderRadius: 4,
}}
/>
),
render: (_, record) => {
const imageUrl = record.thumbnailInstanceId
? `/file/image-low-res/${record.thumbnailInstanceId}?Authorization=${getAuthization()}`
: `/file/image-low-res/${record.instanceId}?Authorization=${getAuthization()}`;
return (
<div
style={{
width: 100,
height: 100,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{(record.duration || record.thumbnailInstanceId) && (
<PlayCircleOutlined style={{ fontSize: '24px', color: 'rgba(255,255,255,0.8)' }} />
)}
</div>
);
},
},
{
title: '名称',

View File

@@ -37,10 +37,10 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
uploading
}) => {
const beforeUpload = (file: UploadFile) => {
// 允许上传图片
const isImage = file.type?.startsWith('image/');
if (!isImage) {
console.error(`${file.name} 不是图片文件`);
// 允许上传图片和视频
const isImageOrVideo = file.type?.startsWith('image/') || file.type?.startsWith('video/');
if (!isImageOrVideo) {
console.error(`${file.name} 不是图片或视频文件`);
return false;
}
return true;
@@ -75,12 +75,13 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
customRequest={({ file }) => onUpload(file as UploadFile)}
showUploadList={false}
multiple
accept="image/*,video/*"
>
<Button
icon={<UploadOutlined />}
loading={uploading}
>
</Button>
</Upload>
<Button onClick={onBatchModeToggle}></Button>

View File

@@ -1,12 +1,17 @@
// src/pages/gallery\components\GridView.tsx
import { ImageItem } from '@/pages/gallery/typings';
import { DeleteOutlined, DownloadOutlined, EyeOutlined, MoreOutlined, LoadingOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd';
import React, { FC, useCallback, useState, useEffect } from 'react';
import useAuthImageUrls from '@/components/Hooks/useAuthImageUrls';
import { fetchImage } from '@/services/file/api';
import '../index.css';
import { formatDuration } from '@/utils/timelineUtils';
import { getAuthization } from '@/utils/userUtils';
import {
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
MoreOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd';
import React, { FC, useCallback } from 'react';
import '../index.css';
interface GridViewProps {
imageList: ImageItem[];
@@ -81,13 +86,17 @@ const GridView: FC<GridViewProps> = ({
);
// 根据视图模式确定图像 URL
const getImageUrl = (instanceId: string, isHighRes?: boolean) => {
const getImageUrl = (item: ImageItem, isHighRes?: boolean) => {
// 如果是视频,使用封面图
if (item.thumbnailInstanceId) {
return `/file/image-low-res/${item.thumbnailInstanceId}?Authorization=${getAuthization()}`;
}
// 小图模式使用低分辨率图像,除非明确要求高清
if (viewMode === 'small' && !isHighRes) {
return `/file/image-low-res/${instanceId}?Authorization=${getAuthization()}`;
return `/file/image-low-res/${item.instanceId}?Authorization=${getAuthization()}`;
}
// 其他模式使用原图
return `/file/image/${instanceId}?Authorization=${getAuthization()}`;
return `/file/image/${item.instanceId}?Authorization=${getAuthization()}`;
};
return (
@@ -109,7 +118,7 @@ const GridView: FC<GridViewProps> = ({
style={{
width: imageSize.width,
height: imageSize.height,
backgroundImage: `url(${getImageUrl(item.instanceId, false)})`,
backgroundImage: `url(${getImageUrl(item, false)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
@@ -119,7 +128,27 @@ const GridView: FC<GridViewProps> = ({
position: 'relative',
}}
onClick={() => !batchMode && onPreview(index)}
/>
>
{(item.duration || item.thumbnailInstanceId) && (
<PlayCircleOutlined style={{ fontSize: '32px', color: 'rgba(255,255,255,0.8)' }} />
)}
{item.duration && (
<span
style={{
position: 'absolute',
bottom: 4,
right: 4,
background: 'rgba(0,0,0,0.5)',
color: '#fff',
padding: '2px 4px',
borderRadius: 2,
fontSize: 10,
}}
>
{formatDuration(item.duration)}
</span>
)}
</div>
<div className="image-info">
<div className="image-title" title={item.imageName}>
{item.imageName}
@@ -139,4 +168,4 @@ const GridView: FC<GridViewProps> = ({
);
};
export default GridView;
export default GridView;

View File

@@ -1,6 +1,6 @@
import { ImageItem } from '@/pages/gallery/typings';
import { formatBytes } from '@/utils/timelineUtils';
import { DeleteOutlined, DownloadOutlined, EyeOutlined } from '@ant-design/icons';
import { DeleteOutlined, DownloadOutlined, EyeOutlined, PlayCircleOutlined } from '@ant-design/icons';
import { Button, Card, Checkbox, Spin } from 'antd';
import React, { FC } from 'react';
import '../index.css';
@@ -37,7 +37,12 @@ const ListView: FC<ListViewProps> = ({
onScroll={onScroll}
style={{ maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }}
>
{imageList.map((item: ImageItem, index: number) => (
{imageList.map((item: ImageItem, index: number) => {
const imageUrl = item.thumbnailInstanceId
? `/file/image/${item.thumbnailInstanceId}?Authorization=${getAuthization()}`
: `/file/image/${item.instanceId}?Authorization=${getAuthization()}`;
return (
<Card key={item.instanceId} className="list-item-card" size="small">
<div className="list-item">
{batchMode && (
@@ -51,14 +56,21 @@ const ListView: FC<ListViewProps> = ({
style={{
width: imageSize.width,
height: imageSize.height,
backgroundImage: `url(/file/image/${item.instanceId}?Authorization=${getAuthization()})`,
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => !batchMode && onPreview(index)}
/>
>
{(item.duration || item.thumbnailInstanceId) && (
<PlayCircleOutlined style={{ fontSize: '48px', color: 'rgba(255,255,255,0.8)' }} />
)}
</div>
<div className="list-item-info">
<div className="image-title" title={item.imageName}>
{item.imageName}
@@ -86,7 +98,7 @@ const ListView: FC<ListViewProps> = ({
</div>
</div>
</Card>
))}
)})}
{loadingMore && (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin />

View File

@@ -1,6 +1,15 @@
// src/pages/gallery/index.tsx
import { ImageItem } from '@/pages/gallery/typings';
import { deleteImage, getImagesList, uploadImage, fetchImage } from '@/services/file/api';
import {
deleteImage,
fetchImage,
getImagesList,
getUploadUrl,
getVideoUrl,
saveFileMetadata,
uploadImage,
} from '@/services/file/api';
import { getAuthization } from '@/utils/userUtils';
import { PageContainer } from '@ant-design/pro-components';
import { useRequest } from '@umijs/max';
import type { RadioChangeEvent } from 'antd';
@@ -12,7 +21,6 @@ import GalleryToolbar from './components/GalleryToolbar';
import GridView from './components/GridView';
import ListView from './components/ListView';
import './index.css';
import { getAuthization } from '@/utils/userUtils';
const Gallery: FC = () => {
const [viewMode, setViewMode] = useState<'small' | 'large' | 'list' | 'table'>('small');
@@ -22,6 +30,12 @@ const Gallery: FC = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [batchMode, setBatchMode] = useState(false);
const [uploading, setUploading] = useState(false);
const [videoPreviewVisible, setVideoPreviewVisible] = useState(false);
const [currentVideo, setCurrentVideo] = useState<{
videoUrl: string;
thumbnailUrl?: string;
} | null>(null);
const pageSize = 50;
const initPagination = { current: 1, pageSize: 5 };
@@ -110,10 +124,33 @@ const Gallery: FC = () => {
);
// 处理图片预览
const handlePreview = useCallback((index: number) => {
setPreviewCurrent(index);
setPreviewVisible(true);
}, []);
const handlePreview = useCallback(
async (index: number) => {
const item = imageList[index];
if (item.duration || item.thumbnailInstanceId) {
// Check if it's a video
try {
const res = await getVideoUrl(item.instanceId);
if (res.code === 200 && res.data) {
setCurrentVideo({
videoUrl: res.data,
thumbnailUrl: item.thumbnailInstanceId,
});
setVideoPreviewVisible(true);
} else {
message.error('获取视频地址失败');
}
} catch (e) {
console.error(e);
message.error('获取视频地址失败');
}
} else {
setPreviewCurrent(index);
setPreviewVisible(true);
}
},
[imageList],
);
// 关闭预览
const handlePreviewClose = useCallback(() => {
@@ -129,19 +166,19 @@ const Gallery: FC = () => {
const handleDownload = useCallback(async (instanceId: string, imageName?: string) => {
try {
// 使用项目中已有的fetchImage API它会自动通过请求拦截器添加认证token
const {data: response} = await fetchImage(instanceId);
const { data: response } = await fetchImage(instanceId);
if (response) {
// 创建一个临时的URL用于下载
const blob = response;
const url = URL.createObjectURL(blob);
// 创建一个临时的下载链接
const link = document.createElement('a');
link.href = url;
link.download = imageName ?? 'image';
link.click();
// 清理临时URL
URL.revokeObjectURL(url);
}
@@ -151,12 +188,128 @@ const Gallery: FC = () => {
}
}, []);
// 上传图片
const handleVideoUpload = async (file: UploadFile) => {
setUploading(true);
const hide = message.loading('正在处理视频...', 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
await fetch(uploadUrl, {
method: 'PUT',
body: file as any,
});
// 3. 生成缩略图
let thumbnailInstanceId = '';
let duration = 0;
try {
const videoEl = document.createElement('video');
videoEl.preload = 'metadata';
videoEl.src = URL.createObjectURL(file as any);
videoEl.muted = true;
videoEl.playsInline = true;
await new Promise((resolve, reject) => {
videoEl.onloadedmetadata = () => {
// 尝试跳转到第1秒以获取更好的缩略图
videoEl.currentTime = Math.min(1, videoEl.duration);
};
videoEl.onseeked = () => resolve(true);
videoEl.onerror = reject;
});
duration = Math.floor(videoEl.duration);
const canvas = document.createElement('canvas');
canvas.width = videoEl.videoWidth;
canvas.height = videoEl.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(videoEl, 0, 0, canvas.width, canvas.height);
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;
}
}
}
}
URL.revokeObjectURL(videoEl.src);
} catch (e) {
console.warn('缩略图生成失败', e);
}
// 4. 保存视频元数据
await saveFileMetadata({
imageName: file.name,
objectKey: fileName,
size: file.size,
contentType: file.type || 'video/mp4',
thumbnailInstanceId: thumbnailInstanceId,
duration: duration,
});
message.success('视频上传成功');
// 刷新列表
if (viewMode === 'table') {
fetchTableData({
current: tablePagination.current,
pageSize: tablePagination.pageSize,
});
} else {
refresh();
}
} catch (error) {
console.error(error);
message.error('视频上传失败');
} finally {
hide();
setUploading(false);
}
};
// 上传图片/视频
const handleUpload = useCallback(
async (file: UploadFile) => {
// 检查文件类型
// 优先检查 mime type
let isVideo = file.type?.startsWith('video/');
// 如果 mime type 不明确,检查扩展名
if (!isVideo && file.name) {
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext && ['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
isVideo = true;
}
}
if (isVideo) {
await handleVideoUpload(file);
return;
}
if (!file.type?.startsWith('image/')) {
message.error('只能上传图片文件!');
message.error('只能上传图片或视频文件!');
return;
}
@@ -403,6 +556,28 @@ const Gallery: FC = () => {
renderView()
)}
{/* 视频预览 Modal */}
<Modal
open={videoPreviewVisible}
footer={null}
onCancel={() => {
setVideoPreviewVisible(false);
setCurrentVideo(null);
}}
width={800}
destroyOnClose
centered
>
{currentVideo && (
<video
src={currentVideo.videoUrl}
controls
autoPlay
style={{ width: '100%', maxHeight: '80vh' }}
/>
)}
</Modal>
{/* 预览组件 - 使用认证后的图像URL */}
<Image.PreviewGroup
preview={{
@@ -430,4 +605,4 @@ const Gallery: FC = () => {
);
};
export default Gallery;
export default Gallery;

View File

@@ -5,4 +5,6 @@ export interface ImageItem {
createTime?: string;
updateTime?: string;
uploadTime?: string;
thumbnailInstanceId?: string;
duration?: number;
}

View File

@@ -0,0 +1,91 @@
import { PageContainer } from '@ant-design/pro-components';
import { useParams, useServerData, history } from '@umijs/max';
import React from 'react';
import { Helmet } from 'react-helmet';
import { Card, Typography, Avatar, Result, Button } from 'antd';
import TimelineImage from '@/components/TimelineImage';
import ReactPlayer from 'react-player';
import useStyles from './style.style';
const { Title, Paragraph } = Typography;
const SharePage: React.FC = () => {
const { styles } = useStyles();
const params = useParams();
const { shareId } = params;
const serverData = useServerData();
const storyItem = serverData?.storyItem;
const handleOpenInApp = () => {
// TODO: Implement deep linking logic here
// Example: window.location.href = `timeline://story/${shareId}`;
console.log('Attempting to open in app...');
};
if (!storyItem) {
return (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={<Button type="primary" onClick={() => history.push('/')}>Back Home</Button>}
/>
);
}
return (
<PageContainer ghost className={styles.sharePage}>
<Helmet>
<title>{storyItem.title}</title>
<meta name="description" content={storyItem.description} />
<meta property="og:title" content={storyItem.title} />
<meta property="og:description" content={storyItem.description} />
{/* <meta property="og:image" content={storyItem.coverUrl} /> */}
</Helmet>
<Card
className={styles.card}
extra={<Button type="primary" onClick={handleOpenInApp}> App </Button>}
>
<Card.Meta
className={styles.meta}
avatar={<Avatar src={storyItem.authorAvatar} />}
title={storyItem.authorName}
description={storyItem.createTime}
/>
<Title level={2} className={styles.title}>
{storyItem.title}
</Title>
<Paragraph>{storyItem.description}</Paragraph>
{storyItem.video && (
<div className={styles.playerWrapper}>
<ReactPlayer
className={styles.reactPlayer}
url={`/api/file/download/${storyItem.video}`}
width="100%"
height="100%"
controls
/>
</div>
)}
{storyItem.images?.map((image: string) => (
<TimelineImage
key={image}
className={styles.timelineImage} // Add a specific class for styling if needed
src={`/api/file/image/${image}`}
placeholder={`/api/file/image-low-res/${image}`}
alt={storyItem.title}
/>
))}
</Card>
</PageContainer>
);
};
export default SharePage;
export async function data() {
const { shareId } = this.params;
const res = await fetch(`http://localhost:8082/story/item/public/story/item/${shareId}`);
const data = await res.json();
return { storyItem: data.data };
}

View File

@@ -0,0 +1,30 @@
import { createStyles } from 'antd-style';
export default createStyles(({ css, token }) => ({
sharePage: css`
background-color: ${token.colorBgLayout};
padding: 24px;
`,
card: css`
max-width: 800px;
margin: auto;
border-radius: 8px;
box-shadow: ${token.boxShadowSecondary};
`,
meta: css`
margin-bottom: 16px;
`,
title: css`
margin-bottom: 16px;
`,
playerWrapper: css`
position: relative;
padding-top: 56.25%; /* 16:9 aspect ratio */
margin-bottom: 16px;
`,
reactPlayer: css`
position: absolute;
top: 0;
left: 0;
`,
}));

View File

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

View File

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

View File

@@ -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 && (

View File

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

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

View File

@@ -43,6 +43,7 @@ export interface StoryType {
storyTime: string;
logo?: string;
permissionType?: number;
items?: StoryItem[];
}
export interface BaseResponse {
code: number;
@@ -65,6 +66,9 @@ export interface StoryItem {
updateTime: string; // YYYY-MM-DD
location?: string;
coverInstanceId?: string; // 封面图
videoUrl?: string;
duration?: number;
thumbnailUrl?: string;
images?: string[]; // 多张图片
subItems?: StoryItem[];
isRoot: number;
@@ -74,6 +78,7 @@ export interface StoryItem {
createName?: string;
}
export interface StoryItemTimeQueryParams {
storyInstanceId?: string;
beforeTime?: string;
afterTime?: string;
}

View File

@@ -1,17 +1,18 @@
// src/pages/story/detail.tsx
import { useIsMobile } from '@/hooks/useIsMobile';
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
import { judgePermission } from '@/pages/story/utils/utils';
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useRequest } from '@umijs/max';
import { history, useParams, useRequest } from '@umijs/max';
import { Button, Empty, FloatButton, message, Spin } from 'antd';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from '@umijs/max';
import { PullToRefresh } from 'antd-mobile';
import { useCallback, useEffect, useRef, useState } from 'react';
import './detail.css';
import {judgePermission} from "@/pages/story/utils/utils";
// 格式化时间数组为易读格式
const formatTimeArray = (time: string | number[] | undefined): string => {
@@ -28,6 +29,7 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
};
const Index = () => {
const isMobile = useIsMobile();
const { id: lineId } = useParams<{ id: string }>();
const containerRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<StoryItem[]>([]);
@@ -79,12 +81,15 @@ const Index = () => {
setHasMoreNew(true);
setLoadDirection('init');
setLoading(true);
run({ current: 1 });
queryDetail();
run();
}, [lineId]);
// 处理响应数据
useEffect(() => {
if (!response) return;
const fetched = response.list || [];
// 兼容 response.list 和 response.data.list
// @ts-ignore
const fetched = response.list || response.data?.list || [];
const pageSize = pagination.pageSize;
const noMore = !(fetched.length === pageSize);
@@ -94,6 +99,8 @@ const Index = () => {
setHasMoreOld(false);
} else if (loadDirection === 'newer') {
setHasMoreNew(false);
} else if (loadDirection === 'init' || loadDirection === 'refresh') {
setItems([]);
}
setLoading(false);
setIsRefreshing(false);
@@ -123,13 +130,12 @@ const Index = () => {
hasShownNoMoreNewRef.current = true;
message.info('没有更多更新内容了');
}
} else if (loadDirection === 'refresh') {
// 刷新操作
} else {
} else if (loadDirection === 'refresh' || loadDirection === 'init') {
setItems(fetched);
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
if (fetched.length > 0) {
setCurrentTimeArr([fetched[0]?.storyItemTime, fetched[fetched.length - 1]?.storyItemTime]);
}
setHasMoreOld(!noMore);
setHasMoreNew(true);
}
setLoading(false);
@@ -163,7 +169,7 @@ const Index = () => {
setLoadDirection('newer');
setIsRefreshing(true);
setLoading(true);
run({ current: 1 });
run({ afterTime: afterTime });
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
// 监听滚动事件
@@ -173,10 +179,10 @@ const Index = () => {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
// 显示回到顶部按钮
setShowScrollTop(scrollTop > 300);
// 接近底部时加载更多
if (scrollHeight - scrollTop - clientHeight < 200) {
loadOlder();
@@ -208,12 +214,13 @@ const Index = () => {
// 按日期分组items并在每个组内按时间排序
const groupItemsByDate = (items: StoryItem[]) => {
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } = {};
items.forEach(item => {
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } =
{};
items.forEach((item) => {
let dateKey = '';
let sortValue = 0;
if (Array.isArray(item.storyItemTime)) {
const [year, month, day] = item.storyItemTime;
dateKey = `${year}${month}${day}`;
@@ -224,34 +231,34 @@ const Index = () => {
dateKey = datePart;
sortValue = new Date(datePart).getTime();
}
if (!groups[dateKey]) {
groups[dateKey] = { dateKey, items: [], sortValue };
}
groups[dateKey].items.push(item);
});
// 对每个日期组内的项目按时间排序(从早到晚)
Object.keys(groups).forEach(dateKey => {
Object.keys(groups).forEach((dateKey) => {
groups[dateKey].items.sort((a, b) => {
const timeA = getTimeValue(a.storyItemTime);
const timeB = getTimeValue(b.storyItemTime);
return timeA - timeB;
});
});
return groups;
};
// 将时间转换为可比较的数值
const getTimeValue = (time: string | number[] | undefined): number => {
if (!time) return 0;
if (Array.isArray(time)) {
const [year, month, day, hour = 0, minute = 0, second = 0] = time;
return new Date(year, month - 1, day, hour, minute, second).getTime();
}
return new Date(String(time)).getTime();
};
@@ -289,54 +296,61 @@ const Index = () => {
}}
>
{items.length > 0 ? (
<>
<PullToRefresh onRefresh={loadNewer} disabled={!isMobile}>
{hasMoreNew && !isMobile && (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<Button onClick={loadNewer} loading={isRefreshing}>
</Button>
</div>
)}
{Object.values(groupedItems)
.sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前)
.map(({ dateKey, items: dateItems }) => (
<div key={dateKey}>
<h2 className="timeline-section-header">{dateKey}</h2>
<div className="timeline-grid-wrapper">
{dateItems.map((item, index) => {
// 调试确保每个item都有有效的数据
if (!item || (!item.id && !item.instanceId)) {
console.warn('发现无效的item:', item, 'at index:', index);
return null; // 不渲染无效的item
}
return (
<TimelineGridItem
key={item.id ?? item.instanceId}
item={item}
handleOption={(
item: StoryItem,
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
) => {
setCurrentItem(item);
setCurrentOption(option);
setOpenAddItemModal(true);
}}
onOpenDetail={(item: StoryItem) => {
setDetailItem(item);
setOpenDetailDrawer(true);
}}
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
refresh={() => {
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
setLoadDirection('refresh');
run({ current: 1 });
queryDetail();
}}
/>
);
})}
<div key={dateKey}>
<h2 className="timeline-section-header">{dateKey}</h2>
<div className="timeline-grid-wrapper">
{dateItems.map((item, index) => {
// 调试确保每个item都有有效的数据
if (!item || (!item.id && !item.instanceId)) {
console.warn('发现无效的item:', item, 'at index:', index);
return null; // 不渲染无效的item
}
return (
<TimelineGridItem
key={item.id ?? item.instanceId}
item={item}
handleOption={(
item: StoryItem,
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
) => {
setCurrentItem(item);
setCurrentOption(option);
setOpenAddItemModal(true);
}}
onOpenDetail={(item: StoryItem) => {
setDetailItem(item);
setOpenDetailDrawer(true);
}}
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
refresh={() => {
setPagination((prev) => ({ ...prev, current: 1 }));
hasShownNoMoreOldRef.current = false;
hasShownNoMoreNewRef.current = false;
setLoadDirection('refresh');
run({ current: 1 });
queryDetail();
}}
/>
);
})}
</div>
</div>
</div>
))}
))}
{loading && <div className="load-indicator">...</div>}
{!loading && !hasMoreOld && <div className="no-more-data"></div>}
{/* 回到顶部按钮 */}
{showScrollTop && (
<div
@@ -355,15 +369,15 @@ const Index = () => {
/>
</div>
)}
</>
</PullToRefresh>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
textAlign: 'center',
}}
>
@@ -372,8 +386,8 @@ const Index = () => {
<Spin size="large" />
<div
style={{
marginTop: 16,
fontSize: '16px',
marginTop: 16,
fontSize: '16px',
color: '#666',
}}
>
@@ -381,14 +395,14 @@ const Index = () => {
</div>
</>
) : (
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{
height: 60,
}}
>
<>
<Empty
description="暂无时间线数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
imageStyle={{
height: 60,
}}
>
<div
style={{
fontSize: '16px',
@@ -396,22 +410,22 @@ const Index = () => {
marginBottom: '16px',
}}
>
</div>
</Empty>
<Button
type="primary"
size="large"
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
</Button>
</>
</div>
</Empty>
<Button
type="primary"
size="large"
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
onClick={() => {
setCurrentOption('add');
setCurrentItem(undefined);
setOpenAddItemModal(true);
}}
>
</Button>
</>
)}
</div>
)}

View File

@@ -1,15 +1,16 @@
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useRequest } from '@umijs/max';
import { Avatar, Button, Card, Dropdown, Input, List, Modal } from 'antd';
import { history, useRequest, useSearchParams } from '@umijs/max';
import { Avatar, Button, Card, Dropdown, Input, List, message, Modal } from 'antd';
import type { FC } from 'react';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import OperationModal from './components/OperationModal';
import type { StoryType } from './data.d';
import { addStory, deleteStory, queryTimelineList, updateStory } from './service';
import type { StoryType, StoryItem } from './data.d';
import { addStory, deleteStory, queryTimelineList, updateStory, searchStoryItems } from './service';
import useStyles from './style.style';
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
import {judgePermission} from "@/pages/story/utils/utils";
import Highlight from '@/components/Highlight';
const { Search } = Input;
@@ -47,10 +48,63 @@ const ListContent = ({
};
export const BasicList: FC = () => {
const { styles } = useStyles();
const [searchParams] = useSearchParams();
const [done, setDone] = useState<boolean>(false);
const [open, setVisible] = useState<boolean>(false);
const [authorizeModelOpen, setAuthorizeModelOpen] = useState<boolean>(false);
const [current, setCurrent] = useState<Partial<StoryType> | undefined>(undefined);
const [isSearching, setIsSearching] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<StoryItem[]>([]);
const [searchPagination, setSearchPagination] = useState<{
current: number;
pageSize: number;
total: number;
keyword?: string;
}>({ current: 1, pageSize: 10, total: 0 });
const { loading: searchLoading, run: searchRun } = useRequest(
(params) => {
// 兼容参数处理:支持直接传入 params 对象或者不传(使用 searchPagination 状态)
const finalParams = params || {
keyword: searchPagination.keyword,
page: searchPagination.current,
pageSize: searchPagination.pageSize,
};
if (!finalParams.keyword) return Promise.resolve({ list: [], total: 0 });
return searchStoryItems({
keyword: finalParams.keyword,
page: finalParams.page || finalParams.pageNum, // 兼容 pageNum 参数
pageSize: finalParams.pageSize,
});
},
{
manual: true, // 改为手动触发,避免与 useEffect 冲突
onSuccess: (data) => {
if (data) {
setSearchResults(data.list || []);
setSearchPagination((prev) => ({
...prev,
total: data.total || 0,
}));
}
},
}
);
// 监听 URL 参数变化,自动触发搜索
useEffect(() => {
const keyword = searchParams.get('keyword');
const page = parseInt(searchParams.get('page') || '1', 10);
if (keyword) {
setSearchPagination(prev => ({ ...prev, keyword, current: page }));
setIsSearching(true);
searchRun({ keyword, page, pageSize: 10 });
} else {
setIsSearching(false);
}
}, [searchParams]);
const {
data: listData,
loading,
@@ -61,6 +115,7 @@ export const BasicList: FC = () => {
storyName,
});
});
const { run: postRun } = useRequest(
(method, params) => {
if (method === 'remove') {
@@ -95,7 +150,6 @@ export const BasicList: FC = () => {
});
};
const editAndDelete = (key: string | number, currentItem: StoryType) => {
console.log(currentItem);
if (key === 'edit') showEditModal(currentItem);
else if (key === 'delete') {
Modal.confirm({
@@ -124,9 +178,9 @@ export const BasicList: FC = () => {
</Button>
<Search
className={styles.extraContentSearch}
placeholder="请输入"
placeholder="请输入故事名称"
onSearch={(value) => {
run(value);
history.push(`/story?keyword=${value}&page=1`);
}}
/>
</div>
@@ -180,66 +234,124 @@ export const BasicList: FC = () => {
}}
extra={extraContent}
>
<List
size="large"
rowKey="id"
loading={loading}
pagination={paginationProps}
dataSource={list}
renderItem={(item: StoryType) => (
<List.Item
actions={[
<a
key="edit"
disabled={!judgePermission(item?.permissionType, 'edit')}
onClick={(e) => {
e.preventDefault();
showEditModal(item);
}}
>
</a>,
// 增加授权操作,可以授权给其他用户
<a
key="authorize"
disabled={!judgePermission(item?.permissionType, 'auth')}
onClick={(e) => {
e.preventDefault();
setCurrent(item);
setAuthorizeModelOpen(true);
}}
>
</a>,
<a
key="delete"
disabled={!judgePermission(item?.permissionType, 'delete')}
onClick={(e) => {
e.preventDefault();
deleteItem(item.instanceId ?? '');
}}
>
</a>,
]}
>
<List.Item.Meta
avatar={<Avatar src={item.logo} shape="square" size="large" />}
title={
<div style={{ marginBottom: 16 }}>
<Search
addonBefore="全文搜索"
placeholder="在所有时间线中搜索内容..."
onSearch={(value) => {
if (value) {
setSearchPagination({ ...searchPagination, current: 1, keyword: value });
searchRun({ keyword: value, pageNum: 1, pageSize: 10 });
} else {
setIsSearching(false);
setSearchResults([]);
}
}}
/>
</div>
{isSearching ? (
<div>
<Button onClick={() => setIsSearching(false)} style={{ marginBottom: 16 }}>
</Button>
<List
size="large"
rowKey="id"
loading={searchLoading}
dataSource={searchResults}
pagination={{
...searchPagination,
onChange: (page, pageSize) => {
history.push(`/story?keyword=${searchPagination.keyword}&page=${page}`);
},
}}
renderItem={(item: StoryItem) => (
<List.Item>
<List.Item.Meta
title={
<a onClick={() => history.push(`/story/${item.storyInstanceId}`)}>
<Highlight text={item.title} keyword={searchPagination.keyword || ''} />
</a>
}
description={<Highlight text={item.content} keyword={searchPagination.keyword || ''} />}
/>
</List.Item>
)}
/>
</div>
) : (
<List
size="large"
rowKey="id"
loading={loading}
pagination={paginationProps}
dataSource={list}
renderItem={(item: StoryType) => (
<List.Item
actions={[
<a
onClick={() => {
history.push(`/timeline/${item.instanceId}`);
key="edit"
disabled={!judgePermission(item?.permissionType, 'edit')}
onClick={(e) => {
e.preventDefault();
showEditModal(item);
}}
>
{item.title}
</a>
}
description={item.description}
/>
<ListContent data={item} />
</List.Item>
)}
/>
</a>,
// 增加授权操作,可以授权给其他用户
<a
key="authorize"
disabled={!judgePermission(item?.permissionType, 'auth')}
onClick={(e) => {
e.preventDefault();
setCurrent(item);
setAuthorizeModelOpen(true);
}}
>
</a>,
<a
key="delete"
disabled={!judgePermission(item?.permissionType, 'delete')}
onClick={(e) => {
e.preventDefault();
deleteItem(item.instanceId ?? '');
}}
>
</a>,
<a
key="share"
onClick={(e) => {
e.preventDefault();
const shareLink = `${window.location.origin}/share/${item.shareId}`;
navigator.clipboard.writeText(shareLink);
message.success('分享链接已复制到剪贴板');
}}
>
</a>,
]}
>
<List.Item.Meta
avatar={<Avatar src={item.logo} shape="square" size="large" />}
title={
<a
onClick={() => {
history.push(`/timeline/${item.instanceId}`);
}}
>
{item.title}
</a>
}
description={item.description}
/>
<ListContent data={item} />
</List.Item>
)}
/>
)}
</Card>
</div>
</PageContainer>

View File

@@ -88,12 +88,19 @@ export async function queryStoryItemImages(itemId: string): Promise<{ data: stri
method: 'GET',
});
}
export async function removeStoryItem(itemId: string): Promise<CommonResponse<string>> {
return request(`/story/item/${itemId}`, {
export async function removeStoryItem(instanceId: string) {
return request(`/api/story/item/${instanceId}`, {
method: 'DELETE',
});
}
export async function searchStoryItems(params: { keyword: string; pageNum: number; pageSize: number }) {
return request('/api/story/item/search', {
method: 'GET',
params,
});
}
export async function fetchImage(imageInstanceId: string): Promise<any> {
return request(`/file/image/${imageInstanceId}`, {
method: 'GET',