故事详情排版修改
This commit is contained in:
parent
efd3f4a82c
commit
30e9e1c7b2
|
@ -16,6 +16,7 @@ interface Props {
|
||||||
imageInstanceId?: string;
|
imageInstanceId?: string;
|
||||||
imageList?: ImageItem[];
|
imageList?: ImageItem[];
|
||||||
currentIndex?: number;
|
currentIndex?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineImage: React.FC<Props> = (props) => {
|
const TimelineImage: React.FC<Props> = (props) => {
|
||||||
|
@ -28,6 +29,7 @@ const TimelineImage: React.FC<Props> = (props) => {
|
||||||
height = 200,
|
height = 200,
|
||||||
imageList = [],
|
imageList = [],
|
||||||
currentIndex = 0,
|
currentIndex = 0,
|
||||||
|
style,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
|
const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
|
||||||
|
@ -55,6 +57,7 @@ const TimelineImage: React.FC<Props> = (props) => {
|
||||||
}}
|
}}
|
||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
|
style={style}
|
||||||
alt={title}
|
alt={title}
|
||||||
fallback={
|
fallback={
|
||||||
fallback ??
|
fallback ??
|
||||||
|
|
|
@ -5,6 +5,7 @@ import pages from './zh-CN/pages';
|
||||||
import pwa from './zh-CN/pwa';
|
import pwa from './zh-CN/pwa';
|
||||||
import settingDrawer from './zh-CN/settingDrawer';
|
import settingDrawer from './zh-CN/settingDrawer';
|
||||||
import settings from './zh-CN/settings';
|
import settings from './zh-CN/settings';
|
||||||
|
import story from "@/locales/zh-CN/story";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
'navBar.lang': '语言',
|
'navBar.lang': '语言',
|
||||||
|
@ -21,4 +22,5 @@ export default {
|
||||||
...settings,
|
...settings,
|
||||||
...pwa,
|
...pwa,
|
||||||
...component,
|
...component,
|
||||||
|
...story,
|
||||||
};
|
};
|
||||||
|
|
|
@ -64,13 +64,4 @@ export default {
|
||||||
'pages.searchTable.tenThousand': '万',
|
'pages.searchTable.tenThousand': '万',
|
||||||
'pages.searchTable.batchDeletion': '批量删除',
|
'pages.searchTable.batchDeletion': '批量删除',
|
||||||
'pages.searchTable.batchApproval': '批量审批',
|
'pages.searchTable.batchApproval': '批量审批',
|
||||||
"story.deleteSuccess": "删除成功",
|
|
||||||
"story.deleteFailed": "删除失败",
|
|
||||||
"story.deleteConfirm": "确认删除",
|
|
||||||
"story.deleteConfirmDescription": "确定要删除这个故事项吗?此操作不可撤销。",
|
|
||||||
"story.yes": "是",
|
|
||||||
"story.no": "否",
|
|
||||||
"story.edit": "编辑",
|
|
||||||
"story.addSubItem": "添加子项",
|
|
||||||
"story.delete": "删除"
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
export default {
|
||||||
|
"story.deleteSuccess": "删除成功",
|
||||||
|
"story.deleteFailed": "删除失败",
|
||||||
|
"story.deleteConfirm": "确认删除",
|
||||||
|
"story.deleteConfirmDescription": "确定要删除这个故事项吗?此操作不可撤销。",
|
||||||
|
"story.yes": "是",
|
||||||
|
"story.no": "否",
|
||||||
|
"story.edit": "编辑",
|
||||||
|
"story.addSubItem": "添加子项",
|
||||||
|
"story.delete": "删除",
|
||||||
|
'story.showLess': '更少',
|
||||||
|
'story.showMore': '更多',
|
||||||
|
}
|
|
@ -41,7 +41,6 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||||
onOk,
|
onOk,
|
||||||
storyId,
|
storyId,
|
||||||
initialValues,
|
initialValues,
|
||||||
storyItemId,
|
|
||||||
option,
|
option,
|
||||||
}) => {
|
}) => {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
@ -60,6 +59,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||||
const searchInputRef = useRef<InputRef>(null);
|
const searchInputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log(initialValues)
|
||||||
if (initialValues && option.startsWith('edit')) {
|
if (initialValues && option.startsWith('edit')) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
title: initialValues.title,
|
title: initialValues.title,
|
||||||
|
@ -68,7 +68,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||||
description: initialValues.description,
|
description: initialValues.description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [initialValues, option]);
|
}, [initialValues, option, visible]);
|
||||||
|
|
||||||
// 获取图库图片
|
// 获取图库图片
|
||||||
const fetchGalleryImages = async (page: number = 1, keyword: string = '') => {
|
const fetchGalleryImages = async (page: number = 1, keyword: string = '') => {
|
||||||
|
@ -80,13 +80,13 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
keyword: keyword,
|
keyword: keyword,
|
||||||
});
|
});
|
||||||
const images = response.data.list.map((img: any) => ({
|
const images = response?.data?.list.map((img: any) => ({
|
||||||
instanceId: img.instanceId,
|
instanceId: img.instanceId,
|
||||||
imageName: img.imageName,
|
imageName: img.imageName,
|
||||||
url: `/file/image-low-res/${img.instanceId}`,
|
url: `/file/image-low-res/${img.instanceId}`,
|
||||||
}));
|
})) ?? [];
|
||||||
setGalleryImages(images);
|
setGalleryImages(images);
|
||||||
setTotal(response.data.total);
|
setTotal(response?.data?.total ?? 0);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取图库图片失败');
|
message.error('获取图库图片失败');
|
||||||
|
@ -321,6 +321,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
||||||
onCancel();
|
onCancel();
|
||||||
}}
|
}}
|
||||||
width={800}
|
width={800}
|
||||||
|
zIndex={1001}
|
||||||
footer={[
|
footer={[
|
||||||
<Button key="back" onClick={onCancel}>
|
<Button key="back" onClick={onCancel}>
|
||||||
取消
|
取消
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
|
// TimelineItem.tsx
|
||||||
import TimelineImage from '@/components/TimelineImage';
|
import TimelineImage from '@/components/TimelineImage';
|
||||||
import { StoryItem } from '@/pages/story/data';
|
import { StoryItem } from '@/pages/story/data';
|
||||||
import {
|
import { DeleteOutlined, DownOutlined, EditOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
DeleteOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
UpOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useIntl, useRequest } from '@umijs/max';
|
import { useIntl, useRequest } from '@umijs/max';
|
||||||
import { Button, Card, message, Popconfirm } from 'antd';
|
import { Button, Card, message, Popconfirm } from 'antd';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
@ -135,6 +131,7 @@ const TimelineItem: React.FC<{
|
||||||
key={imageInstanceId + index}
|
key={imageInstanceId + index}
|
||||||
title={imageInstanceId}
|
title={imageInstanceId}
|
||||||
imageInstanceId={imageInstanceId}
|
imageInstanceId={imageInstanceId}
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }} // 添加响应式样式
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
@ -169,7 +166,13 @@ const TimelineItem: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<TimelineItemDrawer storyItem={item} open={openDetail} setOpen={setOpenDetail} />
|
<TimelineItemDrawer
|
||||||
|
storyItem={item}
|
||||||
|
open={openDetail}
|
||||||
|
setOpen={setOpenDetail}
|
||||||
|
handleDelete={handleDelete}
|
||||||
|
handOption={handleOption}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
// index.style.ts
|
||||||
import { createStyles } from 'antd-style';
|
import { createStyles } from 'antd-style';
|
||||||
|
|
||||||
const useStyles = createStyles(({ token }) => {
|
const useStyles = createStyles(({ token }) => {
|
||||||
|
@ -11,6 +12,17 @@ const useStyles = createStyles(({ token }) => {
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
boxShadow: token.boxShadowSecondary,
|
boxShadow: token.boxShadowSecondary,
|
||||||
},
|
},
|
||||||
|
position: 'relative',
|
||||||
|
padding: '20px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
scrollBehavior: 'smooth',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
[`@media (max-width: 768px)`]: {
|
||||||
|
padding: '10px',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -84,6 +96,23 @@ const useStyles = createStyles(({ token }) => {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: token.colorText,
|
color: token.colorText,
|
||||||
},
|
},
|
||||||
|
timelineItemImages: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '10px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
},
|
||||||
|
timelineImage: {
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
img: {
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
objectFit: 'cover',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
|
// src/pages/story/components/TimelineItemDrawer.tsx
|
||||||
import TimelineImage from '@/components/TimelineImage';
|
import TimelineImage from '@/components/TimelineImage';
|
||||||
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
|
||||||
import SubTimeLineItemModal from '@/pages/story/components/SubTimeLineItemModal';
|
|
||||||
import { StoryItem } from '@/pages/story/data';
|
import { StoryItem } from '@/pages/story/data';
|
||||||
import { queryStoryItemImages } from '@/pages/story/service';
|
import { queryStoryItemImages } from '@/pages/story/service';
|
||||||
import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons';
|
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import { useRequest } from '@umijs/max';
|
import { useIntl, useRequest } from '@umijs/max';
|
||||||
import { Button, Drawer, Space } from 'antd';
|
import { Button, Divider, Drawer, Popconfirm, Space } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
storyItem: StoryItem;
|
storyItem: StoryItem;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
handleDelete: () => void;
|
||||||
|
handOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineItemDrawer = (props: Props) => {
|
const TimelineItemDrawer: React.FC<Props> = (props) => {
|
||||||
const { storyItem, open, setOpen } = props;
|
const { storyItem, open, setOpen, handleDelete, handOption } = props;
|
||||||
const [editModalVisible, setEditModalVisible] = React.useState(false);
|
const intl = useIntl();
|
||||||
const [openAddSubItemModal, setOpenAddSubItemModal] = React.useState(false);
|
|
||||||
const [openEditMainItemModal, setOpenEditMainItemModal] = React.useState(false);
|
|
||||||
const { data: imagesList, run } = useRequest(
|
const { data: imagesList, run } = useRequest(
|
||||||
async (itemId) => {
|
async (itemId) => {
|
||||||
return await queryStoryItemImages(itemId);
|
return await queryStoryItemImages(itemId);
|
||||||
|
@ -27,107 +27,144 @@ const TimelineItemDrawer = (props: Props) => {
|
||||||
manual: true,
|
manual: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
run(storyItem.instanceId);
|
run(storyItem.instanceId);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
const handleEditMainItem = (updatedItem: any) => {
|
|
||||||
const mergedEvent = {
|
// 格式化日期显示
|
||||||
...storyItem,
|
const formatDate = (dateString: string) => {
|
||||||
...updatedItem,
|
if (!dateString) return '';
|
||||||
};
|
const date = new Date(dateString);
|
||||||
setOpenEditMainItemModal(false);
|
return date.toLocaleString('zh-CN', {
|
||||||
};
|
year: 'numeric',
|
||||||
const handleAddSubItem = () => {
|
month: '2-digit',
|
||||||
setOpenAddSubItemModal(false);
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 主时间点详情抽屉 */}
|
{/* 主时间点详情抽屉 */}
|
||||||
<Drawer
|
<Drawer
|
||||||
width={1000}
|
width={800}
|
||||||
placement="right"
|
placement="right"
|
||||||
onClose={closeDrawer}
|
onClose={closeDrawer}
|
||||||
open={open}
|
open={open}
|
||||||
title={storyItem.title}
|
zIndex={1000}
|
||||||
|
title={
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0 }}>{storyItem.title}</h2>
|
||||||
|
<div style={{ fontSize: '14px', color: '#888', marginTop: '4px' }}>
|
||||||
|
{storyItem.storyItemTime} {storyItem.location ? `于${storyItem.location}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
icon={<PlusCircleOutlined />}
|
icon={<EditOutlined />}
|
||||||
type="primary"
|
onClick={() => {
|
||||||
onClick={() => setOpenAddSubItemModal(true)}
|
return handOption(storyItem, 'edit');
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
添加子时间点
|
|
||||||
</Button>
|
|
||||||
<Button icon={<EditOutlined />} onClick={() => setEditModalVisible(true)}>
|
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
|
<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
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
danger
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={intl.formatMessage({ id: 'story.delete' })}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
<Button onClick={closeDrawer}>关闭</Button>
|
<Button onClick={closeDrawer}>关闭</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p>
|
<div style={{ padding: '0 24px' }}>
|
||||||
<strong>描述:</strong> {storyItem.description}
|
<div style={{ marginBottom: '24px' }}>
|
||||||
</p>
|
<h3>描述</h3>
|
||||||
<p>
|
<p style={{ fontSize: '16px', lineHeight: '1.6' }}>{storyItem.description}</p>
|
||||||
<strong>日期:</strong> {storyItem.storyItemTime}
|
</div>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>位置:</strong> {storyItem.location}
|
|
||||||
</p>
|
|
||||||
{/* 封面图 */}
|
|
||||||
{storyItem.coverInstanceId && (
|
|
||||||
<>
|
|
||||||
<p>
|
|
||||||
<strong>封面图:</strong>
|
|
||||||
</p>
|
|
||||||
<TimelineImage
|
|
||||||
title={storyItem.title + 'cover'}
|
|
||||||
imageInstanceId={storyItem.coverInstanceId}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 时刻图库 */}
|
<Divider />
|
||||||
{imagesList && imagesList.length > 0 && (
|
|
||||||
<>
|
{/* 时刻图库 */}
|
||||||
<p>
|
{imagesList && imagesList.length > 0 && (
|
||||||
<strong>时刻图库:</strong>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
</p>
|
<h3>时刻图库</h3>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', marginBottom: '20px' }}>
|
<div
|
||||||
{imagesList.map((imageInstanceId, index) => (
|
style={{
|
||||||
<TimelineImage
|
display: 'grid',
|
||||||
key={imageInstanceId + index}
|
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||||
title={imageInstanceId}
|
gap: '16px',
|
||||||
imageInstanceId={imageInstanceId}
|
marginTop: '16px',
|
||||||
/>
|
}}
|
||||||
))}
|
>
|
||||||
|
{imagesList.map((imageInstanceId, index) => (
|
||||||
|
<div
|
||||||
|
key={imageInstanceId + index}
|
||||||
|
style={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
aspectRatio: '1/1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimelineImage
|
||||||
|
title={imageInstanceId}
|
||||||
|
imageInstanceId={imageInstanceId}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
<Divider />
|
||||||
|
|
||||||
{/* 添加子时间点模态框 */}
|
{/* 创建和更新信息 */}
|
||||||
<AddTimeLineItemModal
|
<div style={{ marginBottom: '24px' }}>
|
||||||
visible={openAddSubItemModal}
|
<h3>记录信息</h3>
|
||||||
onOk={handleAddSubItem}
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', marginTop: '16px' }}>
|
||||||
onCancel={() => setOpenAddSubItemModal(false)}
|
<div style={{ flex: '1', minWidth: '200px' }}>
|
||||||
lineId={storyItem.storyInstanceId}
|
<div style={{ color: '#888', marginBottom: '4px' }}>创建时间</div>
|
||||||
storyItemId={storyItem.instanceId}
|
<div style={{ fontSize: '16px' }}>{formatDate(storyItem.createTime)}</div>
|
||||||
/>
|
</div>
|
||||||
|
<div style={{ flex: '1', minWidth: '200px' }}>
|
||||||
{/* 编辑主时间点模态框 */}
|
<div style={{ color: '#888', marginBottom: '4px' }}>更新时间</div>
|
||||||
<SubTimeLineItemModal
|
<div style={{ fontSize: '16px' }}>{formatDate(storyItem.updateTime)}</div>
|
||||||
visible={openEditMainItemModal}
|
</div>
|
||||||
onOk={handleEditMainItem}
|
<div style={{ flex: '1', minWidth: '200px' }}>
|
||||||
onCancel={() => setOpenEditMainItemModal(false)}
|
<div style={{ color: '#888', marginBottom: '4px' }}>更新人</div>
|
||||||
initialValues={storyItem}
|
<div style={{ fontSize: '16px' }}>系统用户</div>
|
||||||
/>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,33 +4,35 @@ import TimelineItem from '@/pages/story/components/TimelineItem/TimelineItem';
|
||||||
import { StoryItem } from '@/pages/story/data';
|
import { StoryItem } from '@/pages/story/data';
|
||||||
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
|
import { queryStoryDetail, queryStoryItem } from '@/pages/story/service';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { history, useIntl, useRequest } from '@umijs/max';
|
import { history, useRequest } from '@umijs/max';
|
||||||
import { FloatButton, Spin } from 'antd';
|
import { FloatButton, Spin, Empty, Button } from 'antd';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import { FixedSizeList as List } from 'react-window';
|
import { VariableSizeList as List } from 'react-window';
|
||||||
|
import { SyncOutlined } from '@ant-design/icons';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import useStyles from './style.style';
|
|
||||||
|
|
||||||
interface TimelineItemProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { id: lineId } = useParams<{ id: string }>();
|
const { id: lineId } = useParams<{ id: string }>();
|
||||||
const { styles } = useStyles();
|
|
||||||
const intl = useIntl();
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<any>(null);
|
||||||
const [items, setItems] = useState<StoryItem[]>([]);
|
const [items, setItems] = useState<StoryItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMoreOld, setHasMoreOld] = useState(true); // 是否有更老的数据
|
||||||
|
const [hasMoreNew, setHasMoreNew] = useState(true); // 是否有更新的数据
|
||||||
const [openAddItemModal, setOpenAddItemModal] = useState(false);
|
const [openAddItemModal, setOpenAddItemModal] = useState(false);
|
||||||
const [currentItem, setCurrentItem] = useState<StoryItem>();
|
const [currentItem, setCurrentItem] = useState<StoryItem>();
|
||||||
const [currentOption, setCurrentOption] = useState<
|
const [currentOption, setCurrentOption] = useState<
|
||||||
'add' | 'edit' | 'addSubItem' | 'editSubItem'
|
'add' | 'edit' | 'addSubItem' | 'editSubItem'
|
||||||
>();
|
>();
|
||||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||||
|
// 存储每个item的高度
|
||||||
|
const [itemSizes, setItemSizes] = useState<Record<string, number>>({});
|
||||||
|
// 存储已测量高度的item ID集合
|
||||||
|
const measuredItemsRef = useRef<Set<string>>(new Set());
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
|
||||||
|
const [showScrollTop, setShowScrollTop] = useState(false); // 是否显示回到顶部按钮
|
||||||
|
|
||||||
const { data: response, run } = useRequest(
|
const { data: response, run } = useRequest(
|
||||||
() => {
|
() => {
|
||||||
|
@ -53,7 +55,11 @@ const Index = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
setPagination({ current: 1, pageSize: 10 });
|
setPagination({ current: 1, pageSize: 10 });
|
||||||
setHasMore(true);
|
setHasMoreOld(true);
|
||||||
|
setHasMoreNew(true);
|
||||||
|
// 重置高度缓存
|
||||||
|
setItemSizes({});
|
||||||
|
measuredItemsRef.current = new Set();
|
||||||
run();
|
run();
|
||||||
}, [lineId]);
|
}, [lineId]);
|
||||||
|
|
||||||
|
@ -64,74 +70,219 @@ const Index = () => {
|
||||||
if (pagination.current === 1) {
|
if (pagination.current === 1) {
|
||||||
// 首页数据
|
// 首页数据
|
||||||
setItems(response.list || []);
|
setItems(response.list || []);
|
||||||
} else {
|
} else if (pagination.current > 1) {
|
||||||
// 追加数据
|
// 追加更老的数据
|
||||||
setItems(prev => [...prev, ...(response.list || [])]);
|
setItems(prev => [...prev, ...(response.list || [])]);
|
||||||
|
} else if (pagination.current < 1) {
|
||||||
|
// 在前面插入更新的数据
|
||||||
|
setItems(prev => [...(response.list || []), ...prev]);
|
||||||
|
// 保持滚动位置
|
||||||
|
setTimeout(() => {
|
||||||
|
if (listRef.current && response.list) {
|
||||||
|
listRef.current.scrollToItem(response.list.length, 'start');
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否还有更多数据
|
// 检查是否还有更多数据
|
||||||
setHasMore(response.list && response.list.length === pagination.pageSize);
|
if (pagination.current >= 1) {
|
||||||
|
// 检查是否有更老的数据
|
||||||
|
setHasMoreOld(response.list && response.list.length === pagination.pageSize);
|
||||||
|
} else if (pagination.current < 1) {
|
||||||
|
// 检查是否有更新的数据
|
||||||
|
setHasMoreNew(response.list && response.list.length === pagination.pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
}, [response, pagination]);
|
}, [response, pagination]);
|
||||||
|
|
||||||
// 滚动到底部加载更多
|
// 滚动到底部加载更老的数据
|
||||||
const loadMore = useCallback(() => {
|
const loadOlder = useCallback(() => {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMoreOld || pagination.current < 1) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setPagination(prev => ({
|
setPagination(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
current: prev.current + 1
|
current: prev.current + 1
|
||||||
}));
|
}));
|
||||||
}, [loading, hasMore]);
|
}, [loading, hasMoreOld, pagination]);
|
||||||
|
|
||||||
|
// 滚动到顶部加载更新的数据
|
||||||
|
const loadNewer = useCallback(() => {
|
||||||
|
if (loading || !hasMoreNew || pagination.current > 1 || isRefreshing) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
current: prev.current - 1
|
||||||
|
}));
|
||||||
|
}, [loading, hasMoreNew, pagination, isRefreshing]);
|
||||||
|
|
||||||
// 当分页变化时重新请求数据
|
// 当分页变化时重新请求数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pagination.current > 1) {
|
if (pagination.current !== 1) {
|
||||||
console.log('分页变化')
|
|
||||||
run();
|
run();
|
||||||
}
|
}
|
||||||
}, [pagination, run]);
|
}, [pagination, run]);
|
||||||
|
|
||||||
|
// 获取item高度的函数
|
||||||
|
const getItemSize = useCallback((index: number) => {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) return 300; // 默认高度
|
||||||
|
|
||||||
|
// 如果已经测量过该item的高度,则使用缓存的值
|
||||||
|
if (itemSizes[item.id]) {
|
||||||
|
return itemSizes[item.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回默认高度
|
||||||
|
return 300;
|
||||||
|
}, [items, itemSizes]);
|
||||||
|
|
||||||
|
// 当item尺寸发生变化时调用
|
||||||
|
const onItemResize = useCallback((itemId: string, height: number) => {
|
||||||
|
// 只有当高度发生变化时才更新
|
||||||
|
if (itemSizes[itemId] !== height) {
|
||||||
|
setItemSizes(prev => ({
|
||||||
|
...prev,
|
||||||
|
[itemId]: height
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 通知List组件重新计算尺寸
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.resetAfterIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [itemSizes]);
|
||||||
|
|
||||||
// 渲染单个时间线项的函数
|
// 渲染单个时间线项的函数
|
||||||
const renderTimelineItem = useCallback(
|
const renderTimelineItem = useCallback(
|
||||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||||
|
// 显示加载指示器的条件
|
||||||
|
const showOlderLoading = index === items.length && hasMoreOld && pagination.current >= 1;
|
||||||
|
const showNewerLoading = index === 0 && hasMoreNew && pagination.current < 1 && isRefreshing;
|
||||||
|
|
||||||
|
if (showOlderLoading || showNewerLoading) {
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
<Spin />
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{showNewerLoading ? '正在加载更新的内容...' : '正在加载更多内容...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={style}>
|
<div style={style}>
|
||||||
<TimelineItem
|
<div
|
||||||
item={item}
|
ref={(el) => {
|
||||||
handleOption={(
|
// 当元素被渲染时测量其实际高度
|
||||||
item: StoryItem,
|
if (el && !measuredItemsRef.current.has(item.id)) {
|
||||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
measuredItemsRef.current.add(item.id);
|
||||||
) => {
|
// 使用requestAnimationFrame确保DOM已经渲染完成
|
||||||
setCurrentItem(item);
|
requestAnimationFrame(() => {
|
||||||
setCurrentOption(option);
|
if (el) {
|
||||||
setOpenAddItemModal(true);
|
const height = el.getBoundingClientRect().height;
|
||||||
|
onItemResize(item.id, height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
refresh={() => {
|
>
|
||||||
// 刷新当前页数据
|
<TimelineItem
|
||||||
setPagination(prev => ({ ...prev, current: 1 }));
|
item={item}
|
||||||
run();
|
handleOption={(
|
||||||
queryDetail();
|
item: StoryItem,
|
||||||
}}
|
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||||
/>
|
) => {
|
||||||
|
setCurrentItem(item);
|
||||||
|
setCurrentOption(option);
|
||||||
|
setOpenAddItemModal(true);
|
||||||
|
}}
|
||||||
|
refresh={() => {
|
||||||
|
// 刷新当前页数据
|
||||||
|
setPagination(prev => ({ ...prev, current: 1 }));
|
||||||
|
// 重置高度测量
|
||||||
|
measuredItemsRef.current = new Set();
|
||||||
|
run();
|
||||||
|
queryDetail();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[items, run, queryDetail],
|
[items, hasMoreOld, hasMoreNew, pagination, isRefreshing, onItemResize, run, queryDetail],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理滚动事件
|
// 处理滚动事件
|
||||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handleItemsRendered = useCallback(({ visibleStartIndex, visibleStopIndex }) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
// 当可视区域接近列表顶部时加载更新的数据
|
||||||
// 当滚动到底部时加载更多
|
if (visibleStartIndex <= 3 && hasMoreNew && !isRefreshing && pagination.current >= 1) {
|
||||||
if (scrollTop + clientHeight >= scrollHeight - 10) {
|
loadNewer();
|
||||||
loadMore();
|
|
||||||
}
|
}
|
||||||
}, [loadMore]);
|
|
||||||
|
// 当可视区域接近列表底部时加载更老的数据
|
||||||
|
if (visibleStopIndex >= items.length - 3 && hasMoreOld && !loading && pagination.current >= 1) {
|
||||||
|
loadOlder();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 控制回到顶部按钮的显示
|
||||||
|
setShowScrollTop(visibleStartIndex > 5);
|
||||||
|
}, [hasMoreNew, hasMoreOld, isRefreshing, loading, items.length, pagination, loadNewer, loadOlder]);
|
||||||
|
|
||||||
|
// 手动刷新最新数据
|
||||||
|
const handleRefresh = () => {
|
||||||
|
if (isRefreshing) return;
|
||||||
|
|
||||||
|
setIsRefreshing(true);
|
||||||
|
setPagination(prev => ({
|
||||||
|
...prev,
|
||||||
|
current: 0 // 使用0作为刷新标识
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 回到顶部
|
||||||
|
const scrollToTop = () => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollToItem(0, 'start');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听滚动事件,动态显示/隐藏提示信息
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
|
|
||||||
|
// 判断是否在顶部
|
||||||
|
const isTop = scrollTop === 0;
|
||||||
|
// 判断是否在底部
|
||||||
|
const isBottom = scrollTop + clientHeight >= scrollHeight;
|
||||||
|
|
||||||
|
setHasMoreNew(isTop && hasMoreNew); // 更新顶部提示的显示状态
|
||||||
|
setHasMoreOld(isBottom && hasMoreOld); // 更新底部提示的显示状态
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timelineContainer = containerRef.current;
|
||||||
|
if (timelineContainer) {
|
||||||
|
timelineContainer.addEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timelineContainer) {
|
||||||
|
timelineContainer.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [hasMoreNew, hasMoreOld]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
|
@ -139,50 +290,126 @@ const Index = () => {
|
||||||
title={
|
title={
|
||||||
queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
|
queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
|
||||||
}
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
loading={isRefreshing}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="timeline"
|
className="timeline"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
height: 'calc(100vh - 200px)',
|
height: 'calc(100vh - 200px)',
|
||||||
overflowY: 'auto',
|
overflow: 'hidden',
|
||||||
|
position: 'relative'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<AutoSizer>
|
<>
|
||||||
{({ height, width }) => (
|
{/* 顶部提示信息 */}
|
||||||
<div
|
{!hasMoreNew && pagination.current <= 1 && (
|
||||||
style={{ height, width, position: 'relative' }}
|
<div style={{
|
||||||
onScroll={handleScroll}
|
textAlign: 'center',
|
||||||
className="timeline-hide-scrollbar"
|
padding: '12px',
|
||||||
>
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
zIndex: 10,
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
已加载全部更新内容
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AutoSizer>
|
||||||
|
{({ height, width }) => (
|
||||||
<List
|
<List
|
||||||
height={height}
|
ref={listRef}
|
||||||
itemCount={items.length}
|
height={height - (hasMoreNew && pagination.current <= 1 ? 40 : 0) - (hasMoreOld && pagination.current >= 1 ? 40 : 0)}
|
||||||
itemSize={300} // 根据实际项高度调整
|
itemCount={items.length + (hasMoreOld && pagination.current >= 1 ? 1 : 0) + (hasMoreNew && pagination.current < 1 && isRefreshing ? 1 : 0)}
|
||||||
|
itemSize={getItemSize}
|
||||||
width={width}
|
width={width}
|
||||||
|
onItemsRendered={handleItemsRendered}
|
||||||
>
|
>
|
||||||
{renderTimelineItem}
|
{renderTimelineItem}
|
||||||
</List>
|
</List>
|
||||||
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
|
||||||
{/* 加载更多指示器 */}
|
{/* 底部提示信息 */}
|
||||||
{loading && (
|
{!hasMoreOld && pagination.current >= 1 && (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<div style={{
|
||||||
<Spin />
|
textAlign: 'center',
|
||||||
</div>
|
padding: '12px',
|
||||||
)}
|
color: '#999',
|
||||||
|
fontSize: '14px',
|
||||||
{!hasMore && items.length > 0 && (
|
position: 'sticky',
|
||||||
<div style={{ textAlign: 'center', color: '#999', padding: '20px' }}>
|
bottom: 0,
|
||||||
没有更多内容了
|
backgroundColor: '#fff',
|
||||||
</div>
|
zIndex: 10,
|
||||||
)}
|
borderTop: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
已加载全部历史内容
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AutoSizer>
|
|
||||||
|
{/* 回到顶部按钮 */}
|
||||||
|
{showScrollTop && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 20,
|
||||||
|
right: 20,
|
||||||
|
zIndex: 10
|
||||||
|
}}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={scrollToTop}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
<div style={{
|
||||||
{loading ? <Spin /> : '暂无时间线数据'}
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100%',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spin size="large" />
|
||||||
|
<div style={{ marginTop: 16 }}>正在加载时间线数据...</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Empty
|
||||||
|
description="暂无时间线数据"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentOption('add');
|
||||||
|
setCurrentItem();
|
||||||
|
setOpenAddItemModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加第一个时刻
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -206,8 +433,10 @@ const Index = () => {
|
||||||
setOpenAddItemModal(false);
|
setOpenAddItemModal(false);
|
||||||
// 添加新项后刷新数据
|
// 添加新项后刷新数据
|
||||||
setPagination(prev => ({ ...prev, current: 1 }));
|
setPagination(prev => ({ ...prev, current: 1 }));
|
||||||
|
// 重置高度测量
|
||||||
|
measuredItemsRef.current = new Set();
|
||||||
run();
|
run();
|
||||||
queryDetail()
|
queryDetail();
|
||||||
}}
|
}}
|
||||||
storyId={lineId}
|
storyId={lineId}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -9,12 +9,33 @@
|
||||||
transition: transform 0.3s ease;
|
transition: transform 0.3s ease;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline::-webkit-scrollbar {
|
.timeline::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-item-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-content p {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-images {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
/* 主时间线容器 */
|
/* 主时间线容器 */
|
||||||
.timeline-event-container {
|
.timeline-event-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
Loading…
Reference in New Issue