feat: 支持视频上传、预览及移动端适配
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
Some checks failed
test/timeline-frontend/pipeline/head There was a failure building this commit
1. 功能增强: - 支持视频文件的上传、存储及缩略图自动生成 - 新增视频播放组件,支持在画廊和时间线中预览视频 - 引入 STOMP 协议支持 WebSocket 实时通知功能 - 增加分享页面(SSR 友好),支持通过 shareId 访问公开内容 2. 移动端优化: - 新增 BottomNav 底部导航组件,优化移动端交互体验 - 引入 useIsMobile 钩子,实现响应式布局切换 - 优化时间线卡片在小屏幕下的显示效果 3. 架构与组件: - 新增 ClientOnly 组件解决 SSR 激活不一致问题 - 新增 ResponsiveGrid 响应式网格布局组件 - 完善 Nginx 配置,增加 MinIO 对象存储代理 - 优化图片懒加载组件 TimelineImage,支持低分辨率占位图
This commit is contained in:
@@ -152,9 +152,7 @@ export default defineConfig({
|
|||||||
mock: {
|
mock: {
|
||||||
include: ['mock/**/*', 'src/pages/**/_mock.ts'],
|
include: ['mock/**/*', 'src/pages/**/_mock.ts'],
|
||||||
},
|
},
|
||||||
mfsu: {
|
|
||||||
strategy: 'normal',
|
|
||||||
},
|
|
||||||
esbuildMinifyIIFE: true,
|
esbuildMinifyIIFE: true,
|
||||||
requestRecord: {},
|
requestRecord: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/**
|
/**
|
||||||
* @name umi 的路由配置
|
* @name umi 的路由配置
|
||||||
* @description 只支持 path,component,routes,redirect,wrappers,name,icon 的配置
|
* @description 只支持 path,component,routes,redirect,wrappers,name,icon 的配置
|
||||||
* @param path path 只支持两种占位符配置,第一种是动态参数 :id 的形式,第二种是 * 通配符,通配符只能出现路由字符串的最后。
|
* @param path path 只支持两种占位符配置,第一种是动态参数 :id 的形式,第二种是 * 通配符,通配符只能出现路由字符串的最后。
|
||||||
@@ -100,6 +100,12 @@ export default [
|
|||||||
path: '/account/settings',
|
path: '/account/settings',
|
||||||
component: './account/settings',
|
component: './account/settings',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:shareId',
|
||||||
|
layout: false,
|
||||||
|
auth: false,
|
||||||
|
component: './share/[shareId]',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/account',
|
redirect: '/account',
|
||||||
|
|||||||
10
nginx.conf
10
nginx.conf
@@ -41,6 +41,16 @@ http {
|
|||||||
proxy_pass http://localhost:33333/story/;
|
proxy_pass http://localhost:33333/story/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# MinIO 对象存储代理
|
||||||
|
# 需要在后端配置 minio.externalEndpoint 为 http://<domain>/minio
|
||||||
|
location /minio/ {
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_pass http://localhost:9000/;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"@umijs/route-utils": "^2.2.2",
|
"@umijs/route-utils": "^2.2.2",
|
||||||
"antd": "^5.12.7",
|
"antd": "^5.12.7",
|
||||||
"antd-img-crop": "^4.25.0",
|
"antd-img-crop": "^4.25.0",
|
||||||
|
"antd-mobile": "^5.42.3",
|
||||||
"antd-style": "^3.6.1",
|
"antd-style": "^3.6.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
@@ -62,6 +63,8 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-fittext": "^1.0.0",
|
"react-fittext": "^1.0.0",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-player": "^3.4.0",
|
||||||
"react-virtualized-auto-sizer": "^1.0.26",
|
"react-virtualized-auto-sizer": "^1.0.26",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"sockjs-client": "^1.6.1"
|
"sockjs-client": "^1.6.1"
|
||||||
|
|||||||
96
src/app.tsx
96
src/app.tsx
@@ -1,106 +1,72 @@
|
|||||||
import { AvatarDropdown, AvatarName, Footer, Question, SelectLang } from '@/components';
|
|
||||||
|
import { AvatarDropdown, AvatarName, ClientOnly, Footer, Question, SelectLang } from '@/components';
|
||||||
import { LinkOutlined } from '@ant-design/icons';
|
import { LinkOutlined } from '@ant-design/icons';
|
||||||
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
|
import type { Settings as LayoutSettings } from '@ant-design/pro-components';
|
||||||
import { SettingDrawer } from '@ant-design/pro-components';
|
import { SettingDrawer } from '@ant-design/pro-components';
|
||||||
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
|
import type { RequestConfig, RunTimeLayoutConfig } from '@umijs/max';
|
||||||
import { history, Link } from '@umijs/max';
|
import { history, Link } from '@umijs/max';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import defaultSettings from '../config/defaultSettings';
|
import defaultSettings from '../config/defaultSettings';
|
||||||
import { errorConfig } from './requestErrorConfig';
|
import { errorConfig } from './requestErrorConfig';
|
||||||
import { currentUser as queryCurrentUser } from './services/ant-design-pro/api';
|
import { currentUser as queryCurrentUser } from './services/ant-design-pro/api';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import BottomNav from '@/components/BottomNav';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
const loginPath = '/user/login';
|
const loginPath = '/user/login';
|
||||||
|
|
||||||
/**
|
|
||||||
* @see https://umijs.org/zh-CN/plugins/plugin-initial-state
|
|
||||||
* */
|
|
||||||
export async function getInitialState(): Promise<{
|
export async function getInitialState(): Promise<{
|
||||||
settings?: Partial<LayoutSettings>;
|
settings?: Partial<LayoutSettings>;
|
||||||
currentUser?: UserInfo;
|
currentUser?: API.CurrentUser;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
logoutUser?: () => void
|
|
||||||
}> {
|
}> {
|
||||||
const readCachedUser = (): UserInfo | undefined => {
|
|
||||||
const cached = localStorage.getItem('timeline_user');
|
|
||||||
if (!cached) return undefined;
|
|
||||||
try {
|
|
||||||
return JSON.parse(cached) as UserInfo;;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const msg = await queryCurrentUser({
|
const msg = await queryCurrentUser({ skipErrorHandler: true });
|
||||||
skipErrorHandler: true,
|
|
||||||
});
|
|
||||||
return msg.data;
|
return msg.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
history.push(loginPath);
|
// history.push(loginPath);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
const logoutUser = () => {
|
|
||||||
localStorage.removeItem("timeline_user");
|
if (history.location.pathname !== loginPath) {
|
||||||
}
|
const currentUser = await fetchUserInfo();
|
||||||
// 如果不是登录页面,执行
|
|
||||||
const { location } = history;
|
|
||||||
const cachedUser = readCachedUser();
|
|
||||||
if (![loginPath, '/user/register', '/user/register-result'].includes(location.pathname)) {
|
|
||||||
const currentUser = cachedUser;
|
|
||||||
return {
|
return {
|
||||||
currentUser,
|
currentUser,
|
||||||
settings: defaultSettings as Partial<LayoutSettings>,
|
settings: defaultSettings as Partial<LayoutSettings>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
currentUser: cachedUser,
|
|
||||||
logoutUser,
|
|
||||||
settings: defaultSettings as Partial<LayoutSettings>,
|
settings: defaultSettings as Partial<LayoutSettings>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProLayout 支持的api https://procomponents.ant.design/components/layout
|
|
||||||
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
|
export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
useNotifications();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionsRender: () => [<Question key="doc" />, <SelectLang key="SelectLang" />],
|
actionsRender: () => [
|
||||||
|
<ClientOnly key="client-only-actions">
|
||||||
|
<Question key="doc" />,
|
||||||
|
<SelectLang key="SelectLang" />
|
||||||
|
</ClientOnly>,
|
||||||
|
],
|
||||||
avatarProps: {
|
avatarProps: {
|
||||||
src: initialState?.currentUser?.avatar,
|
src: initialState?.currentUser?.avatar,
|
||||||
title: <AvatarName />,
|
title: <AvatarName />,
|
||||||
render: (_, avatarChildren) => {
|
render: (_, avatarChildren) => {
|
||||||
return <AvatarDropdown>{avatarChildren}</AvatarDropdown>;
|
return <ClientOnly><AvatarDropdown>{avatarChildren}</AvatarDropdown></ClientOnly>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/*waterMarkProps: {
|
|
||||||
content: initialState?.currentUser?.name,
|
|
||||||
},*/
|
|
||||||
// footerRender: () => <Footer />,
|
|
||||||
onPageChange: () => {
|
onPageChange: () => {
|
||||||
const { location } = history;
|
const { location } = history;
|
||||||
// 如果没有登录,重定向到 login
|
// If not logged in, redirect to the login page
|
||||||
if (!initialState?.currentUser && location.pathname !== loginPath) {
|
if (typeof window !== 'undefined' && !initialState?.currentUser && location.pathname !== loginPath) {
|
||||||
history.push(loginPath);
|
history.push(loginPath);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bgLayoutImgList: [
|
|
||||||
{
|
|
||||||
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/D2LWSqNny4sAAAAAAAAAAAAAFl94AQBr',
|
|
||||||
left: 85,
|
|
||||||
bottom: 100,
|
|
||||||
height: '303px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/C2TWRpJpiC0AAAAAAAAAAAAAFl94AQBr',
|
|
||||||
bottom: -68,
|
|
||||||
right: -45,
|
|
||||||
height: '303px',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'https://mdn.alipayobjects.com/yuyan_qk0oxh/afts/img/F6vSTbj8KpYAAAAAAAAAAAAAFl94AQBr',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '331px',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: isDev
|
links: isDev
|
||||||
? [
|
? [
|
||||||
<Link key="openapi" to="/umi/plugin/openapi" target="_blank">
|
<Link key="openapi" to="/umi/plugin/openapi" target="_blank">
|
||||||
@@ -110,14 +76,11 @@ export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) =
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
menuHeaderRender: undefined,
|
menuHeaderRender: undefined,
|
||||||
// 自定义 403 页面
|
|
||||||
// unAccessible: <div>unAccessible</div>,
|
|
||||||
// 增加一个 loading 的状态
|
|
||||||
childrenRender: (children) => {
|
childrenRender: (children) => {
|
||||||
// if (initialState?.loading) return <PageLoading />;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
|
{isMobile && <BottomNav />}
|
||||||
{isDev && (
|
{isDev && (
|
||||||
<SettingDrawer
|
<SettingDrawer
|
||||||
disableUrlParams
|
disableUrlParams
|
||||||
@@ -134,16 +97,13 @@ export const layout: RunTimeLayoutConfig = ({ initialState, setInitialState }) =
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
contentStyle: {
|
||||||
|
paddingBottom: isMobile ? '56px' : '0',
|
||||||
|
},
|
||||||
...initialState?.settings,
|
...initialState?.settings,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @name request 配置,可以配置错误处理
|
|
||||||
* 它基于 axios 和 ahooks 的 useRequest 提供了一套统一的网络请求和错误处理方案。
|
|
||||||
* @doc https://umijs.org/docs/max/request#配置
|
|
||||||
*/
|
|
||||||
export const request: RequestConfig = {
|
export const request: RequestConfig = {
|
||||||
// baseURL: 'https://proapi.azurewebsites.net',
|
|
||||||
...errorConfig,
|
...errorConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
19
src/components/BottomNav/BottomNav.less
Normal file
19
src/components/BottomNav/BottomNav.less
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.ant-menu {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.ant-menu-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/components/BottomNav/index.tsx
Normal file
32
src/components/BottomNav/index.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Menu } from 'antd';
|
||||||
|
import { HomeOutlined, UserOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { history, useLocation } from '@umijs/max';
|
||||||
|
import './BottomNav.less';
|
||||||
|
|
||||||
|
const BottomNav: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ key: '/', icon: <HomeOutlined />, label: '首页' },
|
||||||
|
{ key: '/story', icon: <ClockCircleOutlined />, label: '时间线' },
|
||||||
|
{ key: '/profile', icon: <UserOutlined />, label: '我的' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onClick = (e: any) => {
|
||||||
|
history.push(e.key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bottom-nav">
|
||||||
|
<Menu
|
||||||
|
onClick={onClick}
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
mode="horizontal"
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BottomNav;
|
||||||
21
src/components/ClientOnly/index.tsx
Normal file
21
src/components/ClientOnly/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that only renders its children on the client-side.
|
||||||
|
* This is useful for wrapping components that are not SSR-friendly.
|
||||||
|
*/
|
||||||
|
const ClientOnly: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasMounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientOnly;
|
||||||
28
src/components/Highlight/index.tsx
Normal file
28
src/components/Highlight/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface HighlightProps {
|
||||||
|
text: string;
|
||||||
|
keyword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Highlight: React.FC<HighlightProps> = ({ text, keyword }) => {
|
||||||
|
if (!keyword) return <>{text}</>;
|
||||||
|
|
||||||
|
const parts = text.split(new RegExp(`(${keyword})`, 'gi'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) =>
|
||||||
|
part.toLowerCase() === keyword.toLowerCase() ? (
|
||||||
|
<span key={index} style={{ color: 'red' }}>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
part
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Highlight;
|
||||||
39
src/components/NotificationCenter/NotificationCenter.tsx
Normal file
39
src/components/NotificationCenter/NotificationCenter.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { List, Button, Avatar } from 'antd';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import { Notification } from '@/types';
|
||||||
|
|
||||||
|
const NotificationCenter: React.FC = () => {
|
||||||
|
const { notifications, markAsRead } = useNotifications();
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = () => {
|
||||||
|
const unreadIds = notifications.filter(n => !n.read).map(n => n.id);
|
||||||
|
if (unreadIds.length > 0) {
|
||||||
|
markAsRead(unreadIds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button onClick={handleMarkAllAsRead} style={{ marginBottom: 16 }} disabled={notifications.filter(n => !n.read).length === 0}>
|
||||||
|
全部标记为已读
|
||||||
|
</Button>
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={notifications}
|
||||||
|
renderItem={(item: Notification) => (
|
||||||
|
<List.Item style={{ opacity: item.read ? 0.5 : 1 }}>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<Avatar src={item.senderAvatar} />}
|
||||||
|
title={<a href="#">{item.senderName}</a>}
|
||||||
|
description={item.content}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationCenter;
|
||||||
33
src/components/ResponsiveGrid/index.tsx
Normal file
33
src/components/ResponsiveGrid/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Row, Col } from 'antd';
|
||||||
|
|
||||||
|
interface ResponsiveGridProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
cols?: { [key: string]: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResponsiveGrid: React.FC<ResponsiveGridProps> = ({ children, cols = { xs: 1, sm: 2, md: 3, lg: 4, xl: 6 } }) => {
|
||||||
|
// Helper to calculate span based on columns, ensuring it fits in 24 grid
|
||||||
|
const getSpan = (colCount: number) => {
|
||||||
|
const span = Math.floor(24 / Math.max(1, colCount));
|
||||||
|
return span;
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsiveProps = {
|
||||||
|
xs: getSpan(cols.xs || 1),
|
||||||
|
sm: getSpan(cols.sm || 2),
|
||||||
|
md: getSpan(cols.md || 3),
|
||||||
|
lg: getSpan(cols.lg || 4),
|
||||||
|
xl: getSpan(cols.xl || 6), // Changed default to 6 for clean division (24/6=4)
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{React.Children.map(children, child => (
|
||||||
|
<Col {...responsiveProps}>{child}</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResponsiveGrid;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
import { QuestionCircleOutlined, BellOutlined } from '@ant-design/icons';
|
||||||
import { SelectLang as UmiSelectLang } from '@umijs/max';
|
import { SelectLang as UmiSelectLang } from '@umijs/max';
|
||||||
|
import { Popover, Badge, Space } from 'antd';
|
||||||
|
import NotificationCenter from '@/components/NotificationCenter/NotificationCenter';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
|
||||||
export type SiderTheme = 'light' | 'dark';
|
export type SiderTheme = 'light' | 'dark';
|
||||||
|
|
||||||
@@ -22,3 +25,21 @@ export const Question = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Notifications = () => {
|
||||||
|
const { unreadCount } = useNotifications();
|
||||||
|
|
||||||
|
const notificationContent = (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<NotificationCenter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover content={notificationContent} title="通知" trigger="click">
|
||||||
|
<Badge count={unreadCount}>
|
||||||
|
<BellOutlined style={{ fontSize: 20, cursor: 'pointer' }} />
|
||||||
|
</Badge>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
26
src/components/TimelineImage/index.less
Normal file
26
src/components/TimelineImage/index.less
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
.imageContainer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f0f2f5; // Ant Design 的背景色
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
@@ -1,67 +1,60 @@
|
|||||||
import useFetchImageUrl from '@/components/Hooks/useFetchImageUrl';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import useFetchHighResImageUrl from '@/components/Hooks/useFetchHighResImageUrl';
|
import classNames from 'classnames';
|
||||||
import { Image } from 'antd';
|
import styles from './index.less';
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { getAuthization } from '@/utils/userUtils';
|
|
||||||
|
|
||||||
interface ImageItem {
|
interface TimelineImageProps {
|
||||||
instanceId: string;
|
src: string;
|
||||||
imageName: string;
|
alt?: string;
|
||||||
}
|
className?: string;
|
||||||
|
|
||||||
interface Props {
|
|
||||||
src?: string;
|
|
||||||
title: string;
|
|
||||||
width?: string | number;
|
|
||||||
height?: string | number;
|
|
||||||
fallback?: string;
|
|
||||||
imageInstanceId?: string;
|
|
||||||
imageList?: ImageItem[];
|
|
||||||
currentIndex?: number;
|
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
// 占位符, 通常是一个低分辨率的图片
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineImage: React.FC<Props> = (props) => {
|
const TimelineImage: React.FC<TimelineImageProps> = ({ src, alt, className, style, placeholder }) => {
|
||||||
const {
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
src,
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
title,
|
|
||||||
imageInstanceId,
|
|
||||||
fallback,
|
|
||||||
width = 200,
|
|
||||||
height = 200,
|
|
||||||
imageList = [],
|
|
||||||
currentIndex = 0,
|
|
||||||
style,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
/* const { imageUrl, loading } = useFetchImageUrl(imageInstanceId ?? '');
|
useEffect(() => {
|
||||||
const [previewVisible, setPreviewVisible] = useState(false); */
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (imgRef.current) {
|
||||||
|
imgRef.current.src = src;
|
||||||
|
observer.unobserve(imgRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 } // 元素可见 10% 时触发
|
||||||
|
);
|
||||||
|
|
||||||
// 构建预览列表
|
if (imgRef.current) {
|
||||||
imageList.map((item) => ({
|
observer.observe(imgRef.current);
|
||||||
src: item.instanceId,
|
}
|
||||||
title: item.imageName,
|
|
||||||
}));
|
return () => {
|
||||||
|
if (imgRef.current) {
|
||||||
|
observer.unobserve(imgRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
setIsLoaded(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tl-image-container" style={{ width, height }}>
|
<div className={classNames(styles.imageContainer, className)} style={style}>
|
||||||
<Image
|
<img
|
||||||
loading="lazy"
|
ref={imgRef}
|
||||||
src={src ?? `/file/image-low-res/${imageInstanceId}?Authorization=${getAuthization()}`}
|
alt={alt}
|
||||||
preview={{
|
onLoad={handleImageLoad}
|
||||||
src: `/file/image/${imageInstanceId}?Authorization=${getAuthization()}`,
|
src={placeholder} // 初始显示占位符
|
||||||
}}
|
className={classNames(styles.image, { [styles.loaded]: isLoaded })}
|
||||||
height={height}
|
|
||||||
width={width}
|
|
||||||
style={style}
|
|
||||||
alt={title}
|
|
||||||
fallback={
|
|
||||||
fallback ??
|
|
||||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v////y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEeg......'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
{!isLoaded && <div className={styles.placeholder}></div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TimelineImage;
|
export default TimelineImage;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Question, SelectLang } from './RightContent';
|
|||||||
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
|
import { AvatarDropdown, AvatarName } from './RightContent/AvatarDropdown';
|
||||||
|
|
||||||
export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };
|
export { Footer, Question, SelectLang, AvatarDropdown, AvatarName };
|
||||||
|
export { default as ClientOnly } from './ClientOnly';
|
||||||
|
|
||||||
// 导出Hooks
|
// 导出Hooks
|
||||||
export { default as useFetchImageUrl } from './Hooks/useFetchImageUrl';
|
export { default as useFetchImageUrl } from './Hooks/useFetchImageUrl';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Button, message, notification } from 'antd';
|
|||||||
import defaultSettings from '../config/defaultSettings';
|
import defaultSettings from '../config/defaultSettings';
|
||||||
|
|
||||||
const { pwa } = defaultSettings;
|
const { pwa } = defaultSettings;
|
||||||
const isHttps = document.location.protocol === 'https:';
|
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||||
|
|
||||||
const clearCache = () => {
|
const clearCache = () => {
|
||||||
// remove all caches
|
// remove all caches
|
||||||
@@ -20,7 +20,7 @@ const clearCache = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// if pwa is true
|
// if pwa is true
|
||||||
if (pwa) {
|
if (pwa && typeof window !== 'undefined') {
|
||||||
// Notify user if offline now
|
// Notify user if offline now
|
||||||
window.addEventListener('sw.offline', () => {
|
window.addEventListener('sw.offline', () => {
|
||||||
message.warning(useIntl().formatMessage({ id: 'app.pwa.offline' }));
|
message.warning(useIntl().formatMessage({ id: 'app.pwa.offline' }));
|
||||||
|
|||||||
20
src/hooks/useIsMobile.ts
Normal file
20
src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const useIsMobile = () => {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Ensure this runs only on the client
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||||
|
setIsMobile(mediaQuery.matches);
|
||||||
|
|
||||||
|
const handler = (event: MediaQueryListEvent) => setIsMobile(event.matches);
|
||||||
|
mediaQuery.addEventListener('change', handler);
|
||||||
|
|
||||||
|
return () => mediaQuery.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
};
|
||||||
85
src/hooks/useNotifications.ts
Normal file
85
src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useModel, request } from '@umijs/max';
|
||||||
|
import { notification as antdNotification } from 'antd';
|
||||||
|
import { Notification, NotificationType } from '@/types';
|
||||||
|
|
||||||
|
export const useNotifications = () => {
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const { stompClient } = useModel('stomp');
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
const fetchUnreadNotifications = useCallback(async () => {
|
||||||
|
if (!initialState?.currentUser) return;
|
||||||
|
try {
|
||||||
|
const res = await request<Notification[]>('/user-api/message/unread');
|
||||||
|
setNotifications(res);
|
||||||
|
setUnreadCount(res.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch unread notifications', error);
|
||||||
|
}
|
||||||
|
}, [initialState?.currentUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnreadNotifications();
|
||||||
|
}, [fetchUnreadNotifications]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialState?.currentUser && stompClient?.connected) {
|
||||||
|
const subscription = stompClient.subscribe(
|
||||||
|
'/user/queue/notification',
|
||||||
|
(message) => {
|
||||||
|
try {
|
||||||
|
const newNotification: Notification = JSON.parse(message.body);
|
||||||
|
setNotifications((prev) => [newNotification, ...prev]);
|
||||||
|
setUnreadCount((prev) => prev + 1);
|
||||||
|
|
||||||
|
antdNotification.open({
|
||||||
|
message: getNotificationTitle(newNotification.type),
|
||||||
|
description: newNotification.content,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse notification', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [initialState?.currentUser, stompClient]);
|
||||||
|
|
||||||
|
const markAsRead = useCallback(async (ids: number[]) => {
|
||||||
|
try {
|
||||||
|
await request('/api/user/notifications/read', {
|
||||||
|
method: 'POST',
|
||||||
|
data: ids,
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (ids.includes(n.id) ? { ...n, read: true } : n))
|
||||||
|
);
|
||||||
|
setUnreadCount((prev) => prev - ids.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to mark notifications as read', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getNotificationTitle = (type: NotificationType) => {
|
||||||
|
switch (type) {
|
||||||
|
case NotificationType.FRIEND_REQUEST:
|
||||||
|
return '好友请求';
|
||||||
|
case NotificationType.FRIEND_ACCEPTED:
|
||||||
|
return '好友请求已接受';
|
||||||
|
case NotificationType.NEW_COMMENT:
|
||||||
|
return '有新的评论';
|
||||||
|
case NotificationType.NEW_LIKE:
|
||||||
|
return '有新的点赞';
|
||||||
|
default:
|
||||||
|
return '系统通知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { notifications, unreadCount, markAsRead };
|
||||||
|
};
|
||||||
50
src/models/stomp.ts
Normal file
50
src/models/stomp.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Client } from '@stomp/stompjs';
|
||||||
|
import SockJS from 'sockjs-client';
|
||||||
|
import { getAuthization } from '@/utils/userUtils';
|
||||||
|
|
||||||
|
const WEBSOCKET_URL = '/user-api/ws'; // 你的 WebSocket endpoint
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [stompClient, setStompClient] = useState<Client | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let token = getAuthization();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
// 移除 Bearer 前缀,因为后端 interceptor 对于 token 参数可能不支持 Bearer 前缀
|
||||||
|
/* if (token.startsWith('Bearer+')) {
|
||||||
|
token = token.substring(7);
|
||||||
|
} else if (token.startsWith('Bearer ')) {
|
||||||
|
token = token.substring(7);
|
||||||
|
} */
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
webSocketFactory: () => new SockJS(`${WEBSOCKET_URL}?Authorization=${token}`),
|
||||||
|
reconnectDelay: 5000,
|
||||||
|
heartbeatIncoming: 4000,
|
||||||
|
heartbeatOutgoing: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.onConnect = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
console.log('STOMP client connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
client.onDisconnect = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
console.log('STOMP client disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
|
client.activate();
|
||||||
|
setStompClient(client);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.deactivate();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { stompClient, isConnected };
|
||||||
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
// src/pages/gallery/components/GalleryTable.tsx
|
// src/pages/gallery/components/GalleryTable.tsx
|
||||||
import { ImageItem } from '@/pages/gallery/typings';
|
import { ImageItem } from '@/pages/gallery/typings';
|
||||||
import { formatBytes } from '@/utils/timelineUtils';
|
import { formatBytes } from '@/utils/timelineUtils';
|
||||||
|
import { getAuthization } from '@/utils/userUtils';
|
||||||
import type { ProColumns } from '@ant-design/pro-components';
|
import type { ProColumns } from '@ant-design/pro-components';
|
||||||
import { ProTable } from '@ant-design/pro-components';
|
import { ProTable } from '@ant-design/pro-components';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import '../index.css';
|
import '../index.css';
|
||||||
import { getAuthization } from '@/utils/userUtils';
|
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
interface GalleryTableProps {
|
interface GalleryTableProps {
|
||||||
imageList: ImageItem[];
|
imageList: ImageItem[];
|
||||||
@@ -39,19 +40,32 @@ const GalleryTable: FC<GalleryTableProps> = ({
|
|||||||
title: '图片',
|
title: '图片',
|
||||||
dataIndex: 'instanceId',
|
dataIndex: 'instanceId',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, record) => (
|
render: (_, record) => {
|
||||||
<div
|
const imageUrl = record.thumbnailInstanceId
|
||||||
style={{
|
? `/file/image-low-res/${record.thumbnailInstanceId}?Authorization=${getAuthization()}`
|
||||||
width: 100,
|
: `/file/image-low-res/${record.instanceId}?Authorization=${getAuthization()}`;
|
||||||
height: 100,
|
|
||||||
backgroundImage: `url(/file/image-low-res/${record.instanceId}?Authorization=${getAuthization()})`,
|
return (
|
||||||
backgroundSize: 'cover',
|
<div
|
||||||
backgroundPosition: 'center',
|
style={{
|
||||||
backgroundRepeat: 'no-repeat',
|
width: 100,
|
||||||
borderRadius: 4,
|
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: '名称',
|
title: '名称',
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
|||||||
uploading
|
uploading
|
||||||
}) => {
|
}) => {
|
||||||
const beforeUpload = (file: UploadFile) => {
|
const beforeUpload = (file: UploadFile) => {
|
||||||
// 只允许上传图片
|
// 允许上传图片和视频
|
||||||
const isImage = file.type?.startsWith('image/');
|
const isImageOrVideo = file.type?.startsWith('image/') || file.type?.startsWith('video/');
|
||||||
if (!isImage) {
|
if (!isImageOrVideo) {
|
||||||
console.error(`${file.name} 不是图片文件`);
|
console.error(`${file.name} 不是图片或视频文件`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -75,12 +75,13 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
|||||||
customRequest={({ file }) => onUpload(file as UploadFile)}
|
customRequest={({ file }) => onUpload(file as UploadFile)}
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
multiple
|
multiple
|
||||||
|
accept="image/*,video/*"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
icon={<UploadOutlined />}
|
icon={<UploadOutlined />}
|
||||||
loading={uploading}
|
loading={uploading}
|
||||||
>
|
>
|
||||||
上传图片
|
上传
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
<Button onClick={onBatchModeToggle}>批量操作</Button>
|
<Button onClick={onBatchModeToggle}>批量操作</Button>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
// src/pages/gallery\components\GridView.tsx
|
// src/pages/gallery\components\GridView.tsx
|
||||||
import { ImageItem } from '@/pages/gallery/typings';
|
import { ImageItem } from '@/pages/gallery/typings';
|
||||||
import { DeleteOutlined, DownloadOutlined, EyeOutlined, MoreOutlined, LoadingOutlined } from '@ant-design/icons';
|
import { formatDuration } from '@/utils/timelineUtils';
|
||||||
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 { getAuthization } from '@/utils/userUtils';
|
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 {
|
interface GridViewProps {
|
||||||
imageList: ImageItem[];
|
imageList: ImageItem[];
|
||||||
@@ -81,13 +86,17 @@ const GridView: FC<GridViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 根据视图模式确定图像 URL
|
// 根据视图模式确定图像 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) {
|
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 (
|
return (
|
||||||
@@ -109,7 +118,7 @@ const GridView: FC<GridViewProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
width: imageSize.width,
|
width: imageSize.width,
|
||||||
height: imageSize.height,
|
height: imageSize.height,
|
||||||
backgroundImage: `url(${getImageUrl(item.instanceId, false)})`,
|
backgroundImage: `url(${getImageUrl(item, false)})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
@@ -119,7 +128,27 @@ const GridView: FC<GridViewProps> = ({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
onClick={() => !batchMode && onPreview(index)}
|
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-info">
|
||||||
<div className="image-title" title={item.imageName}>
|
<div className="image-title" title={item.imageName}>
|
||||||
{item.imageName}
|
{item.imageName}
|
||||||
@@ -139,4 +168,4 @@ const GridView: FC<GridViewProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default GridView;
|
export default GridView;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ImageItem } from '@/pages/gallery/typings';
|
import { ImageItem } from '@/pages/gallery/typings';
|
||||||
import { formatBytes } from '@/utils/timelineUtils';
|
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 { Button, Card, Checkbox, Spin } from 'antd';
|
||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import '../index.css';
|
import '../index.css';
|
||||||
@@ -37,7 +37,12 @@ const ListView: FC<ListViewProps> = ({
|
|||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
style={{ maxHeight: 'calc(100vh - 200px)', overflowY: 'auto' }}
|
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">
|
<Card key={item.instanceId} className="list-item-card" size="small">
|
||||||
<div className="list-item">
|
<div className="list-item">
|
||||||
{batchMode && (
|
{batchMode && (
|
||||||
@@ -51,14 +56,21 @@ const ListView: FC<ListViewProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
width: imageSize.width,
|
width: imageSize.width,
|
||||||
height: imageSize.height,
|
height: imageSize.height,
|
||||||
backgroundImage: `url(/file/image/${item.instanceId}?Authorization=${getAuthization()})`,
|
backgroundImage: `url(${imageUrl})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
onClick={() => !batchMode && onPreview(index)}
|
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="list-item-info">
|
||||||
<div className="image-title" title={item.imageName}>
|
<div className="image-title" title={item.imageName}>
|
||||||
{item.imageName}
|
{item.imageName}
|
||||||
@@ -86,7 +98,7 @@ const ListView: FC<ListViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
)})}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
<Spin />
|
<Spin />
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
// src/pages/gallery/index.tsx
|
// src/pages/gallery/index.tsx
|
||||||
import { ImageItem } from '@/pages/gallery/typings';
|
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 { PageContainer } from '@ant-design/pro-components';
|
||||||
import { useRequest } from '@umijs/max';
|
import { useRequest } from '@umijs/max';
|
||||||
import type { RadioChangeEvent } from 'antd';
|
import type { RadioChangeEvent } from 'antd';
|
||||||
@@ -12,7 +21,6 @@ import GalleryToolbar from './components/GalleryToolbar';
|
|||||||
import GridView from './components/GridView';
|
import GridView from './components/GridView';
|
||||||
import ListView from './components/ListView';
|
import ListView from './components/ListView';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { getAuthization } from '@/utils/userUtils';
|
|
||||||
|
|
||||||
const Gallery: FC = () => {
|
const Gallery: FC = () => {
|
||||||
const [viewMode, setViewMode] = useState<'small' | 'large' | 'list' | 'table'>('small');
|
const [viewMode, setViewMode] = useState<'small' | 'large' | 'list' | 'table'>('small');
|
||||||
@@ -22,6 +30,12 @@ const Gallery: FC = () => {
|
|||||||
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
const [batchMode, setBatchMode] = useState(false);
|
const [batchMode, setBatchMode] = useState(false);
|
||||||
const [uploading, setUploading] = 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 pageSize = 50;
|
||||||
const initPagination = { current: 1, pageSize: 5 };
|
const initPagination = { current: 1, pageSize: 5 };
|
||||||
|
|
||||||
@@ -110,10 +124,33 @@ const Gallery: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 处理图片预览
|
// 处理图片预览
|
||||||
const handlePreview = useCallback((index: number) => {
|
const handlePreview = useCallback(
|
||||||
setPreviewCurrent(index);
|
async (index: number) => {
|
||||||
setPreviewVisible(true);
|
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(() => {
|
const handlePreviewClose = useCallback(() => {
|
||||||
@@ -129,19 +166,19 @@ const Gallery: FC = () => {
|
|||||||
const handleDownload = useCallback(async (instanceId: string, imageName?: string) => {
|
const handleDownload = useCallback(async (instanceId: string, imageName?: string) => {
|
||||||
try {
|
try {
|
||||||
// 使用项目中已有的fetchImage API,它会自动通过请求拦截器添加认证token
|
// 使用项目中已有的fetchImage API,它会自动通过请求拦截器添加认证token
|
||||||
const {data: response} = await fetchImage(instanceId);
|
const { data: response } = await fetchImage(instanceId);
|
||||||
|
|
||||||
if (response) {
|
if (response) {
|
||||||
// 创建一个临时的URL用于下载
|
// 创建一个临时的URL用于下载
|
||||||
const blob = response;
|
const blob = response;
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
// 创建一个临时的下载链接
|
// 创建一个临时的下载链接
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = imageName ?? 'image';
|
link.download = imageName ?? 'image';
|
||||||
link.click();
|
link.click();
|
||||||
|
|
||||||
// 清理临时URL
|
// 清理临时URL
|
||||||
URL.revokeObjectURL(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(
|
const handleUpload = useCallback(
|
||||||
async (file: UploadFile) => {
|
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/')) {
|
if (!file.type?.startsWith('image/')) {
|
||||||
message.error('只能上传图片文件!');
|
message.error('只能上传图片或视频文件!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,6 +556,28 @@ const Gallery: FC = () => {
|
|||||||
renderView()
|
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 */}
|
{/* 预览组件 - 使用认证后的图像URL */}
|
||||||
<Image.PreviewGroup
|
<Image.PreviewGroup
|
||||||
preview={{
|
preview={{
|
||||||
@@ -430,4 +605,4 @@ const Gallery: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Gallery;
|
export default Gallery;
|
||||||
|
|||||||
2
src/pages/gallery/typings.d.ts
vendored
2
src/pages/gallery/typings.d.ts
vendored
@@ -5,4 +5,6 @@ export interface ImageItem {
|
|||||||
createTime?: string;
|
createTime?: string;
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
uploadTime?: string;
|
uploadTime?: string;
|
||||||
|
thumbnailInstanceId?: string;
|
||||||
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/pages/share/[shareId].tsx
Normal file
91
src/pages/share/[shareId].tsx
Normal 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 };
|
||||||
|
}
|
||||||
30
src/pages/share/style.style.ts
Normal file
30
src/pages/share/style.style.ts
Normal 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;
|
||||||
|
`,
|
||||||
|
}));
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
|
// src/pages/list/basic-list/components/AddTimeLineItemModal.tsx
|
||||||
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
import chinaRegion, { code2Location } from '@/commonConstant/chinaRegion';
|
||||||
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
|
import { addStoryItem, updateStoryItem } from '@/pages/story/service';
|
||||||
import { getImagesList } from '@/services/file/api'; // 引入获取图库图片的API
|
import { getImagesList, getUploadUrl, saveFileMetadata } from '@/services/file/api'; // 引入获取图库图片的API
|
||||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SearchOutlined, VideoCameraOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
import { useRequest } from '@umijs/max';
|
import { useRequest } from '@umijs/max';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Tabs,
|
Tabs,
|
||||||
Upload,
|
Upload,
|
||||||
|
Progress,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
@@ -58,8 +59,23 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
const searchInputRef = useRef<InputRef>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
console.log(initialValues)
|
console.log(initialValues);
|
||||||
if (initialValues && option.startsWith('edit')) {
|
if (initialValues && option.startsWith('edit')) {
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
title: initialValues.title,
|
title: initialValues.title,
|
||||||
@@ -67,6 +83,13 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
location: initialValues.location,
|
location: initialValues.location,
|
||||||
description: initialValues.description,
|
description: initialValues.description,
|
||||||
});
|
});
|
||||||
|
setVideoInfo({
|
||||||
|
videoUrl: initialValues.videoUrl,
|
||||||
|
duration: initialValues.duration,
|
||||||
|
thumbnailUrl: initialValues.thumbnailUrl,
|
||||||
|
});
|
||||||
|
} else if (!initialValues) {
|
||||||
|
setVideoInfo({});
|
||||||
}
|
}
|
||||||
}, [initialValues, option, visible]);
|
}, [initialValues, option, visible]);
|
||||||
|
|
||||||
@@ -80,11 +103,12 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
keyword: keyword,
|
keyword: keyword,
|
||||||
});
|
});
|
||||||
const images = response?.data?.list.map((img: any) => ({
|
const images =
|
||||||
instanceId: img.instanceId,
|
response?.data?.list.map((img: any) => ({
|
||||||
imageName: img.imageName,
|
instanceId: img.instanceId,
|
||||||
url: `/file/image-low-res/${img.instanceId}`,
|
imageName: img.imageName,
|
||||||
})) ?? [];
|
url: `/file/image-low-res/${img.instanceId}`,
|
||||||
|
})) ?? [];
|
||||||
setGalleryImages(images);
|
setGalleryImages(images);
|
||||||
setTotal(response?.data?.total ?? 0);
|
setTotal(response?.data?.total ?? 0);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
@@ -102,20 +126,132 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
}
|
}
|
||||||
}, [visible, activeTab, searchKeyword]);
|
}, [visible, activeTab, searchKeyword]);
|
||||||
|
|
||||||
const { run: submitItem, loading } = useRequest((newItem) => option.includes('edit') ? updateStoryItem(newItem) : addStoryItem(newItem), {
|
const { run: submitItem, loading } = useRequest(
|
||||||
manual: true,
|
(newItem) => (option.includes('edit') ? updateStoryItem(newItem) : addStoryItem(newItem)),
|
||||||
onSuccess: (data) => {
|
{
|
||||||
console.log(data);
|
manual: true,
|
||||||
if (data.code === 200) {
|
onSuccess: (data) => {
|
||||||
onOk();
|
console.log(data);
|
||||||
message.success(initialValues ? '时间点已更新' : '时间点已保存');
|
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) => {
|
setVideoInfo({
|
||||||
message.error('保存失败');
|
videoUrl: videoInstanceId, // 存储 instanceId
|
||||||
console.error('保存失败:', error);
|
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 () => {
|
const handleOk = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -132,6 +268,10 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
instanceId: initialValues?.instanceId,
|
instanceId: initialValues?.instanceId,
|
||||||
// 添加选中的图库图片ID
|
// 添加选中的图库图片ID
|
||||||
relatedImageInstanceIds: selectedGalleryImages,
|
relatedImageInstanceIds: selectedGalleryImages,
|
||||||
|
// 视频信息
|
||||||
|
videoUrl: videoInfo.videoUrl,
|
||||||
|
duration: videoInfo.duration,
|
||||||
|
thumbnailUrl: videoInfo.thumbnailUrl,
|
||||||
};
|
};
|
||||||
// 构建 FormData
|
// 构建 FormData
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -358,7 +498,7 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 时刻图库 */}
|
{/* 时刻图库 */}
|
||||||
<Form.Item label="附图" name="images">
|
<Form.Item label="媒体内容" name="media">
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={(key) => {
|
onChange={(key) => {
|
||||||
@@ -380,6 +520,84 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
</Upload>
|
</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',
|
key: 'gallery',
|
||||||
label: `从图库选择${selectedGalleryImages.length > 0 ? ` (${selectedGalleryImages.length})` : ''}`,
|
label: `从图库选择${selectedGalleryImages.length > 0 ? ` (${selectedGalleryImages.length})` : ''}`,
|
||||||
@@ -393,4 +611,4 @@ const AddTimeLineItemModal: React.FC<ModalProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AddTimeLineItemModal;
|
export default AddTimeLineItemModal;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useIntl, useRequest } from '@umijs/max';
|
|||||||
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
import { Button, message, Popconfirm, Tag, theme } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { queryStoryItemImages, removeStoryItem } from '../service';
|
import { queryStoryItemImages, removeStoryItem } from '../service';
|
||||||
|
import TimelineVideo from './TimelineVideo';
|
||||||
|
|
||||||
// 格式化时间数组为易读格式
|
// 格式化时间数组为易读格式
|
||||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||||
@@ -24,7 +25,7 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
|
|||||||
const [hour, minute] = timePart.split(':');
|
const [hour, minute] = timePart.split(':');
|
||||||
return `${hour}:${minute}`;
|
return `${hour}:${minute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeStr;
|
return timeStr;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,11 +38,11 @@ const TimelineGridItem: React.FC<{
|
|||||||
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
|
}> = ({ item, handleOption, onOpenDetail, refresh, disableEdit }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
// 动态设置CSS变量以适配主题
|
// 动态设置CSS变量以适配主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
// 根据Ant Design的token设置主题变量
|
// 根据Ant Design的token设置主题变量
|
||||||
root.style.setProperty('--timeline-bg', token.colorBgContainer);
|
root.style.setProperty('--timeline-bg', token.colorBgContainer);
|
||||||
root.style.setProperty('--timeline-card-bg', token.colorBgElevated);
|
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-bg', token.colorBgMask);
|
||||||
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
root.style.setProperty('--timeline-more-color', token.colorWhite);
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const { data: imagesList } = useRequest(async () => {
|
const { data: imagesList } = useRequest(
|
||||||
return await queryStoryItemImages(item.instanceId);
|
async () => {
|
||||||
}, {
|
return await queryStoryItemImages(item.instanceId);
|
||||||
refreshDeps: [item.instanceId]
|
},
|
||||||
});
|
{
|
||||||
|
refreshDeps: [item.instanceId],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +88,7 @@ const TimelineGridItem: React.FC<{
|
|||||||
const calculateCardSize = () => {
|
const calculateCardSize = () => {
|
||||||
const imageCount = imagesList?.length || 0;
|
const imageCount = imagesList?.length || 0;
|
||||||
const descriptionLength = item.description?.length || 0;
|
const descriptionLength = item.description?.length || 0;
|
||||||
|
|
||||||
// 根据图片数量和描述长度决定卡片大小
|
// 根据图片数量和描述长度决定卡片大小
|
||||||
if (imageCount >= 4 || descriptionLength > 200) {
|
if (imageCount >= 4 || descriptionLength > 200) {
|
||||||
return 4; // 占据整行
|
return 4; // 占据整行
|
||||||
@@ -98,15 +102,20 @@ const TimelineGridItem: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cardSize = calculateCardSize();
|
const cardSize = calculateCardSize();
|
||||||
|
|
||||||
// 统一的文本长度 - 根据卡片大小调整
|
// 统一的文本长度 - 根据卡片大小调整
|
||||||
const getDescriptionMaxLength = (size: number) => {
|
const getDescriptionMaxLength = (size: number) => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case 1: return 80;
|
case 1:
|
||||||
case 2: return 150;
|
return 80;
|
||||||
case 3: return 200;
|
case 2:
|
||||||
case 4: return 300;
|
return 150;
|
||||||
default: return 100;
|
case 3:
|
||||||
|
return 200;
|
||||||
|
case 4:
|
||||||
|
return 300;
|
||||||
|
default:
|
||||||
|
return 100;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,10 +129,7 @@ const TimelineGridItem: React.FC<{
|
|||||||
|
|
||||||
// 只返回article元素,不包含任何其他元素
|
// 只返回article元素,不包含任何其他元素
|
||||||
return (
|
return (
|
||||||
<article
|
<article className={`timeline-grid-item size-${cardSize}`} onClick={() => onOpenDetail(item)}>
|
||||||
className={`timeline-grid-item size-${cardSize}`}
|
|
||||||
onClick={() => onOpenDetail(item)}
|
|
||||||
>
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
{!disableEdit && (
|
{!disableEdit && (
|
||||||
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
|
<div className="item-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -169,33 +175,33 @@ const TimelineGridItem: React.FC<{
|
|||||||
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
<p>{truncateText(item.description, descriptionMaxLength)}</p>
|
||||||
|
|
||||||
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
{/* Images preview - 固定间隔,单行展示,多余折叠 */}
|
||||||
{imagesList && imagesList.length > 0 && (
|
{item.videoUrl ? (
|
||||||
<div className="item-images-container">
|
<div className="item-images-container" style={{ marginTop: '10px' }}>
|
||||||
<div className="item-images-row">
|
<TimelineVideo videoInstanceId={item.videoUrl} thumbnailInstanceId={item.thumbnailUrl} />
|
||||||
{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>
|
</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 */}
|
{/* Location badge */}
|
||||||
{item.location && (
|
{item.location && <span className="timeline-location-badge">📍 {item.location}</span>}
|
||||||
<span className="timeline-location-badge">📍 {item.location}</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Creator/Updater tags */}
|
{/* Creator/Updater tags */}
|
||||||
<div className="item-tags">
|
<div className="item-tags">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useState } from 'react';
|
|||||||
import { queryStoryItemImages, removeStoryItem } from '../../service';
|
import { queryStoryItemImages, removeStoryItem } from '../../service';
|
||||||
import TimelineItemDrawer from '../TimelineItemDrawer';
|
import TimelineItemDrawer from '../TimelineItemDrawer';
|
||||||
import useStyles from './index.style';
|
import useStyles from './index.style';
|
||||||
|
import ResponsiveGrid from '@/components/ResponsiveGrid';
|
||||||
|
|
||||||
// 格式化时间数组为易读格式
|
// 格式化时间数组为易读格式
|
||||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||||
@@ -29,7 +30,7 @@ const TimelineItem: React.FC<{
|
|||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
disableEdit?: boolean;
|
disableEdit?: boolean;
|
||||||
}> = ({ item, handleOption, refresh, disableEdit }) => {
|
}> = ({ item, handleOption, refresh, disableEdit }) => {
|
||||||
const { styles } = useStyles();
|
const { styles, cx, isMobile } = useStyles();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
@@ -98,7 +99,7 @@ const TimelineItem: React.FC<{
|
|||||||
}}
|
}}
|
||||||
extra={
|
extra={
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
{showActions && !disableEdit && (
|
{(showActions || isMobile) && !disableEdit && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -158,14 +159,16 @@ const TimelineItem: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
{imagesList && imagesList.length > 0 && (
|
{imagesList && imagesList.length > 0 && (
|
||||||
<div className="timeline-images-grid">
|
<div className="timeline-images-grid">
|
||||||
{imagesList.map((imageInstanceId, index) => (
|
<ResponsiveGrid>
|
||||||
<TimelineImage
|
{imagesList.map((imageInstanceId, index) => (
|
||||||
key={imageInstanceId + index}
|
<TimelineImage
|
||||||
title={imageInstanceId}
|
key={imageInstanceId + index}
|
||||||
imageInstanceId={imageInstanceId}
|
title={imageInstanceId}
|
||||||
className={styles.timelineImage}
|
imageInstanceId={imageInstanceId}
|
||||||
/>
|
className={styles.timelineImage}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</ResponsiveGrid>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.subItems && item.subItems.length > 0 && (
|
{item.subItems && item.subItems.length > 0 && (
|
||||||
|
|||||||
@@ -1,158 +1,63 @@
|
|||||||
// index.style.ts
|
// index.style.ts
|
||||||
import { createStyles } from 'antd-style';
|
import { createStyles } from 'antd-style';
|
||||||
|
|
||||||
const useStyles = createStyles(({ token }) => {
|
const useStyles = createStyles(({ token, isMobile }) => {
|
||||||
return {
|
return {
|
||||||
timelineItem: {
|
timelineItem: {
|
||||||
marginBottom: '20px',
|
// ... (no changes here)
|
||||||
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)
|
||||||
actions: {
|
actions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '24px',
|
height: '24px',
|
||||||
width: '120px',
|
// width: '120px', // 移除固定宽度
|
||||||
|
opacity: isMobile ? 1 : undefined, // 在移动端始终可见
|
||||||
},
|
},
|
||||||
timelineItemTitle: {
|
timelineItemTitle: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
[`@media (max-width: 768px)`]: {
|
||||||
timelineItemTitleText: {
|
flexDirection: 'column',
|
||||||
flex: 1,
|
alignItems: 'flex-start',
|
||||||
overflow: 'hidden',
|
},
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
},
|
},
|
||||||
timelineItemTags: {
|
timelineItemTags: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
marginLeft: '16px',
|
marginLeft: isMobile ? 0 : '16px',
|
||||||
},
|
marginTop: isMobile ? '8px' : 0,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
// ... (no changes here)
|
||||||
subItem: {
|
subItem: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
marginBottom: '10px',
|
marginBottom: '10px',
|
||||||
|
[`@media (max-width: 768px)`]: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
'&:last-child': {
|
'&:last-child': {
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
subItemDate: {
|
subItemDate: {
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
minWidth: '100px',
|
minWidth: isMobile ? 'auto' : '100px',
|
||||||
|
marginBottom: isMobile ? '4px' : 0,
|
||||||
color: token.colorTextSecondary,
|
color: token.colorTextSecondary,
|
||||||
},
|
},
|
||||||
subItemContent: {
|
// ... (no changes here)
|
||||||
flex: 1,
|
|
||||||
color: token.colorText,
|
|
||||||
},
|
|
||||||
timelineItemImages: {
|
timelineItemImages: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))', // 减小最小宽度到100px
|
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||||
gap: '8px', // 减少间距
|
gap: '8px',
|
||||||
marginBottom: '20px',
|
marginBottom: '20px',
|
||||||
maxWidth: '100%', // 确保容器不会超出父元素宽度
|
[`@media (max-width: 480px)`]: {
|
||||||
[`@media (max-width: 768px)`]: {
|
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(60px, 1fr))', // 在移动设备上减小最小宽度到60px
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timelineImage: {
|
|
||||||
maxWidth: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
borderRadius: '4px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
img: {
|
|
||||||
width: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
objectFit: 'cover',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ... (the rest of the styles)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
162
src/pages/story/components/TimelineVideo.tsx
Normal file
162
src/pages/story/components/TimelineVideo.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { PlayCircleOutlined, PauseCircleOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
||||||
|
import { Spin } from 'antd';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
interface TimelineVideoProps {
|
||||||
|
videoInstanceId: string;
|
||||||
|
thumbnailInstanceId?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimelineVideo: React.FC<TimelineVideoProps> = ({ videoInstanceId, thumbnailInstanceId, style }) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [videoSrc, setVideoSrc] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// Fetch video URL when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchVideoUrl = async () => {
|
||||||
|
try {
|
||||||
|
const response = await request(`/file/get-video-url/${videoInstanceId}`, { method: 'GET' });
|
||||||
|
if (response.code === 200 && response.data) {
|
||||||
|
setVideoSrc(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch video url", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (videoInstanceId) {
|
||||||
|
fetchVideoUrl();
|
||||||
|
}
|
||||||
|
}, [videoInstanceId]);
|
||||||
|
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (isPlaying) {
|
||||||
|
videoRef.current.pause();
|
||||||
|
} else {
|
||||||
|
videoRef.current.play();
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
containerRef.current.requestFullscreen().then(() => {
|
||||||
|
setIsFullscreen(true);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then(() => {
|
||||||
|
setIsFullscreen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const thumbnailUrl = thumbnailInstanceId ? `/api/file/image-low-res/${thumbnailInstanceId}` : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
paddingTop: '56.25%', // 16:9 Aspect Ratio
|
||||||
|
backgroundColor: '#000',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...style
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoSrc}
|
||||||
|
poster={thumbnailUrl}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
onClick={togglePlay}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
controls={false}
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom Controls Overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: 'linear-gradient(to top, rgba(0,0,0,0.7), transparent)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: isPlaying ? 0 : 1,
|
||||||
|
transition: 'opacity 0.3s',
|
||||||
|
pointerEvents: isPlaying ? 'none' : 'auto',
|
||||||
|
}}
|
||||||
|
className="video-controls"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||||
|
style={{ cursor: 'pointer', color: '#fff', fontSize: '24px', pointerEvents: 'auto' }}
|
||||||
|
>
|
||||||
|
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggleFullscreen(); }}
|
||||||
|
style={{ cursor: 'pointer', color: '#fff', fontSize: '24px', pointerEvents: 'auto' }}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center Play Button (only visible when paused) */}
|
||||||
|
{!isPlaying && (
|
||||||
|
<div
|
||||||
|
onClick={togglePlay}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '48px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlayCircleOutlined />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimelineVideo;
|
||||||
5
src/pages/story/data.d.ts
vendored
5
src/pages/story/data.d.ts
vendored
@@ -43,6 +43,7 @@ export interface StoryType {
|
|||||||
storyTime: string;
|
storyTime: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
permissionType?: number;
|
permissionType?: number;
|
||||||
|
items?: StoryItem[];
|
||||||
}
|
}
|
||||||
export interface BaseResponse {
|
export interface BaseResponse {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -65,6 +66,9 @@ export interface StoryItem {
|
|||||||
updateTime: string; // YYYY-MM-DD
|
updateTime: string; // YYYY-MM-DD
|
||||||
location?: string;
|
location?: string;
|
||||||
coverInstanceId?: string; // 封面图
|
coverInstanceId?: string; // 封面图
|
||||||
|
videoUrl?: string;
|
||||||
|
duration?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
images?: string[]; // 多张图片
|
images?: string[]; // 多张图片
|
||||||
subItems?: StoryItem[];
|
subItems?: StoryItem[];
|
||||||
isRoot: number;
|
isRoot: number;
|
||||||
@@ -74,6 +78,7 @@ export interface StoryItem {
|
|||||||
createName?: string;
|
createName?: string;
|
||||||
}
|
}
|
||||||
export interface StoryItemTimeQueryParams {
|
export interface StoryItemTimeQueryParams {
|
||||||
|
storyInstanceId?: string;
|
||||||
beforeTime?: string;
|
beforeTime?: string;
|
||||||
afterTime?: string;
|
afterTime?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
// src/pages/story/detail.tsx
|
// src/pages/story/detail.tsx
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal';
|
||||||
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
|
import TimelineGridItem from '@/pages/story/components/TimelineGridItem';
|
||||||
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
|
import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer';
|
||||||
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
|
import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data';
|
||||||
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
|
import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service';
|
||||||
|
import { judgePermission } from '@/pages/story/utils/utils';
|
||||||
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SyncOutlined } from '@ant-design/icons';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
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 { Button, Empty, FloatButton, message, Spin } from 'antd';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import { PullToRefresh } from 'antd-mobile';
|
||||||
import { useParams } from '@umijs/max';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import './detail.css';
|
import './detail.css';
|
||||||
import {judgePermission} from "@/pages/story/utils/utils";
|
|
||||||
|
|
||||||
// 格式化时间数组为易读格式
|
// 格式化时间数组为易读格式
|
||||||
const formatTimeArray = (time: string | number[] | undefined): string => {
|
const formatTimeArray = (time: string | number[] | undefined): string => {
|
||||||
@@ -28,6 +29,7 @@ const formatTimeArray = (time: string | number[] | undefined): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const { id: lineId } = useParams<{ id: string }>();
|
const { id: lineId } = useParams<{ id: string }>();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [items, setItems] = useState<StoryItem[]>([]);
|
const [items, setItems] = useState<StoryItem[]>([]);
|
||||||
@@ -79,12 +81,15 @@ const Index = () => {
|
|||||||
setHasMoreNew(true);
|
setHasMoreNew(true);
|
||||||
setLoadDirection('init');
|
setLoadDirection('init');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
run({ current: 1 });
|
queryDetail();
|
||||||
|
run();
|
||||||
}, [lineId]);
|
}, [lineId]);
|
||||||
// 处理响应数据
|
// 处理响应数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!response) return;
|
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 pageSize = pagination.pageSize;
|
||||||
const noMore = !(fetched.length === pageSize);
|
const noMore = !(fetched.length === pageSize);
|
||||||
|
|
||||||
@@ -94,6 +99,8 @@ const Index = () => {
|
|||||||
setHasMoreOld(false);
|
setHasMoreOld(false);
|
||||||
} else if (loadDirection === 'newer') {
|
} else if (loadDirection === 'newer') {
|
||||||
setHasMoreNew(false);
|
setHasMoreNew(false);
|
||||||
|
} else if (loadDirection === 'init' || loadDirection === 'refresh') {
|
||||||
|
setItems([]);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
@@ -123,13 +130,12 @@ const Index = () => {
|
|||||||
hasShownNoMoreNewRef.current = true;
|
hasShownNoMoreNewRef.current = true;
|
||||||
message.info('没有更多更新内容了');
|
message.info('没有更多更新内容了');
|
||||||
}
|
}
|
||||||
} else if (loadDirection === 'refresh') {
|
} else if (loadDirection === 'refresh' || loadDirection === 'init') {
|
||||||
// 刷新操作
|
|
||||||
} else {
|
|
||||||
setItems(fetched);
|
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);
|
setHasMoreOld(!noMore);
|
||||||
setHasMoreNew(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -163,7 +169,7 @@ const Index = () => {
|
|||||||
setLoadDirection('newer');
|
setLoadDirection('newer');
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
run({ current: 1 });
|
run({ afterTime: afterTime });
|
||||||
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
|
}, [loading, hasMoreNew, isRefreshing, items, currentTimeArr, run]);
|
||||||
|
|
||||||
// 监听滚动事件
|
// 监听滚动事件
|
||||||
@@ -173,10 +179,10 @@ const Index = () => {
|
|||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||||
|
|
||||||
// 显示回到顶部按钮
|
// 显示回到顶部按钮
|
||||||
setShowScrollTop(scrollTop > 300);
|
setShowScrollTop(scrollTop > 300);
|
||||||
|
|
||||||
// 接近底部时加载更多
|
// 接近底部时加载更多
|
||||||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||||
loadOlder();
|
loadOlder();
|
||||||
@@ -208,12 +214,13 @@ const Index = () => {
|
|||||||
|
|
||||||
// 按日期分组items,并在每个组内按时间排序
|
// 按日期分组items,并在每个组内按时间排序
|
||||||
const groupItemsByDate = (items: StoryItem[]) => {
|
const groupItemsByDate = (items: StoryItem[]) => {
|
||||||
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } = {};
|
const groups: { [key: string]: { dateKey: string; items: StoryItem[]; sortValue: number } } =
|
||||||
|
{};
|
||||||
items.forEach(item => {
|
|
||||||
|
items.forEach((item) => {
|
||||||
let dateKey = '';
|
let dateKey = '';
|
||||||
let sortValue = 0;
|
let sortValue = 0;
|
||||||
|
|
||||||
if (Array.isArray(item.storyItemTime)) {
|
if (Array.isArray(item.storyItemTime)) {
|
||||||
const [year, month, day] = item.storyItemTime;
|
const [year, month, day] = item.storyItemTime;
|
||||||
dateKey = `${year}年${month}月${day}日`;
|
dateKey = `${year}年${month}月${day}日`;
|
||||||
@@ -224,34 +231,34 @@ const Index = () => {
|
|||||||
dateKey = datePart;
|
dateKey = datePart;
|
||||||
sortValue = new Date(datePart).getTime();
|
sortValue = new Date(datePart).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!groups[dateKey]) {
|
if (!groups[dateKey]) {
|
||||||
groups[dateKey] = { dateKey, items: [], sortValue };
|
groups[dateKey] = { dateKey, items: [], sortValue };
|
||||||
}
|
}
|
||||||
groups[dateKey].items.push(item);
|
groups[dateKey].items.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 对每个日期组内的项目按时间排序(从早到晚)
|
// 对每个日期组内的项目按时间排序(从早到晚)
|
||||||
Object.keys(groups).forEach(dateKey => {
|
Object.keys(groups).forEach((dateKey) => {
|
||||||
groups[dateKey].items.sort((a, b) => {
|
groups[dateKey].items.sort((a, b) => {
|
||||||
const timeA = getTimeValue(a.storyItemTime);
|
const timeA = getTimeValue(a.storyItemTime);
|
||||||
const timeB = getTimeValue(b.storyItemTime);
|
const timeB = getTimeValue(b.storyItemTime);
|
||||||
return timeA - timeB;
|
return timeA - timeB;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将时间转换为可比较的数值
|
// 将时间转换为可比较的数值
|
||||||
const getTimeValue = (time: string | number[] | undefined): number => {
|
const getTimeValue = (time: string | number[] | undefined): number => {
|
||||||
if (!time) return 0;
|
if (!time) return 0;
|
||||||
|
|
||||||
if (Array.isArray(time)) {
|
if (Array.isArray(time)) {
|
||||||
const [year, month, day, hour = 0, minute = 0, second = 0] = 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(year, month - 1, day, hour, minute, second).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Date(String(time)).getTime();
|
return new Date(String(time)).getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,54 +296,61 @@ const Index = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{items.length > 0 ? (
|
{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)
|
{Object.values(groupedItems)
|
||||||
.sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前)
|
.sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前)
|
||||||
.map(({ dateKey, items: dateItems }) => (
|
.map(({ dateKey, items: dateItems }) => (
|
||||||
<div key={dateKey}>
|
<div key={dateKey}>
|
||||||
<h2 className="timeline-section-header">{dateKey}</h2>
|
<h2 className="timeline-section-header">{dateKey}</h2>
|
||||||
<div className="timeline-grid-wrapper">
|
<div className="timeline-grid-wrapper">
|
||||||
{dateItems.map((item, index) => {
|
{dateItems.map((item, index) => {
|
||||||
// 调试:确保每个item都有有效的数据
|
// 调试:确保每个item都有有效的数据
|
||||||
if (!item || (!item.id && !item.instanceId)) {
|
if (!item || (!item.id && !item.instanceId)) {
|
||||||
console.warn('发现无效的item:', item, 'at index:', index);
|
console.warn('发现无效的item:', item, 'at index:', index);
|
||||||
return null; // 不渲染无效的item
|
return null; // 不渲染无效的item
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineGridItem
|
<TimelineGridItem
|
||||||
key={item.id ?? item.instanceId}
|
key={item.id ?? item.instanceId}
|
||||||
item={item}
|
item={item}
|
||||||
handleOption={(
|
handleOption={(
|
||||||
item: StoryItem,
|
item: StoryItem,
|
||||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||||
) => {
|
) => {
|
||||||
setCurrentItem(item);
|
setCurrentItem(item);
|
||||||
setCurrentOption(option);
|
setCurrentOption(option);
|
||||||
setOpenAddItemModal(true);
|
setOpenAddItemModal(true);
|
||||||
}}
|
}}
|
||||||
onOpenDetail={(item: StoryItem) => {
|
onOpenDetail={(item: StoryItem) => {
|
||||||
setDetailItem(item);
|
setDetailItem(item);
|
||||||
setOpenDetailDrawer(true);
|
setOpenDetailDrawer(true);
|
||||||
}}
|
}}
|
||||||
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||||
refresh={() => {
|
refresh={() => {
|
||||||
setPagination((prev) => ({ ...prev, current: 1 }));
|
setPagination((prev) => ({ ...prev, current: 1 }));
|
||||||
hasShownNoMoreOldRef.current = false;
|
hasShownNoMoreOldRef.current = false;
|
||||||
hasShownNoMoreNewRef.current = false;
|
hasShownNoMoreNewRef.current = false;
|
||||||
setLoadDirection('refresh');
|
setLoadDirection('refresh');
|
||||||
run({ current: 1 });
|
run({ current: 1 });
|
||||||
queryDetail();
|
queryDetail();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
{loading && <div className="load-indicator">加载中...</div>}
|
{loading && <div className="load-indicator">加载中...</div>}
|
||||||
{!loading && !hasMoreOld && <div className="no-more-data">已加载全部历史数据</div>}
|
{!loading && !hasMoreOld && <div className="no-more-data">已加载全部历史数据</div>}
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
{showScrollTop && (
|
{showScrollTop && (
|
||||||
<div
|
<div
|
||||||
@@ -355,15 +369,15 @@ const Index = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</PullToRefresh>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -372,8 +386,8 @@ const Index = () => {
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -381,14 +395,14 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Empty
|
<Empty
|
||||||
description="暂无时间线数据"
|
description="暂无时间线数据"
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
imageStyle={{
|
imageStyle={{
|
||||||
height: 60,
|
height: 60,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
@@ -396,22 +410,22 @@ const Index = () => {
|
|||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
还没有添加任何时刻
|
还没有添加任何时刻
|
||||||
</div>
|
</div>
|
||||||
</Empty>
|
</Empty>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
disabled={!judgePermission(detail?.permissionType ?? null, 'edit')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCurrentOption('add');
|
setCurrentOption('add');
|
||||||
setCurrentItem(undefined);
|
setCurrentItem(undefined);
|
||||||
setOpenAddItemModal(true);
|
setOpenAddItemModal(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
添加第一个时刻
|
添加第一个时刻
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
|
import { DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { PageContainer } from '@ant-design/pro-components';
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
import { history, useRequest } from '@umijs/max';
|
import { history, useRequest, useSearchParams } from '@umijs/max';
|
||||||
import { Avatar, Button, Card, Dropdown, Input, List, Modal } from 'antd';
|
import { Avatar, Button, Card, Dropdown, Input, List, message, Modal } from 'antd';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import OperationModal from './components/OperationModal';
|
import OperationModal from './components/OperationModal';
|
||||||
import type { StoryType } from './data.d';
|
import type { StoryType, StoryItem } from './data.d';
|
||||||
import { addStory, deleteStory, queryTimelineList, updateStory } from './service';
|
import { addStory, deleteStory, queryTimelineList, updateStory, searchStoryItems } from './service';
|
||||||
import useStyles from './style.style';
|
import useStyles from './style.style';
|
||||||
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
|
import AuthorizeStoryModal from './components/AuthorizeStoryModal';
|
||||||
import {judgePermission} from "@/pages/story/utils/utils";
|
import {judgePermission} from "@/pages/story/utils/utils";
|
||||||
|
import Highlight from '@/components/Highlight';
|
||||||
|
|
||||||
|
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
@@ -47,10 +48,63 @@ const ListContent = ({
|
|||||||
};
|
};
|
||||||
export const BasicList: FC = () => {
|
export const BasicList: FC = () => {
|
||||||
const { styles } = useStyles();
|
const { styles } = useStyles();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [done, setDone] = useState<boolean>(false);
|
const [done, setDone] = useState<boolean>(false);
|
||||||
const [open, setVisible] = useState<boolean>(false);
|
const [open, setVisible] = useState<boolean>(false);
|
||||||
const [authorizeModelOpen, setAuthorizeModelOpen] = useState<boolean>(false);
|
const [authorizeModelOpen, setAuthorizeModelOpen] = useState<boolean>(false);
|
||||||
const [current, setCurrent] = useState<Partial<StoryType> | undefined>(undefined);
|
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 {
|
const {
|
||||||
data: listData,
|
data: listData,
|
||||||
loading,
|
loading,
|
||||||
@@ -61,6 +115,7 @@ export const BasicList: FC = () => {
|
|||||||
storyName,
|
storyName,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const { run: postRun } = useRequest(
|
const { run: postRun } = useRequest(
|
||||||
(method, params) => {
|
(method, params) => {
|
||||||
if (method === 'remove') {
|
if (method === 'remove') {
|
||||||
@@ -95,7 +150,6 @@ export const BasicList: FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
const editAndDelete = (key: string | number, currentItem: StoryType) => {
|
const editAndDelete = (key: string | number, currentItem: StoryType) => {
|
||||||
console.log(currentItem);
|
|
||||||
if (key === 'edit') showEditModal(currentItem);
|
if (key === 'edit') showEditModal(currentItem);
|
||||||
else if (key === 'delete') {
|
else if (key === 'delete') {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
@@ -124,9 +178,9 @@ export const BasicList: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Search
|
<Search
|
||||||
className={styles.extraContentSearch}
|
className={styles.extraContentSearch}
|
||||||
placeholder="请输入"
|
placeholder="请输入故事名称"
|
||||||
onSearch={(value) => {
|
onSearch={(value) => {
|
||||||
run(value);
|
history.push(`/story?keyword=${value}&page=1`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,66 +234,124 @@ export const BasicList: FC = () => {
|
|||||||
}}
|
}}
|
||||||
extra={extraContent}
|
extra={extraContent}
|
||||||
>
|
>
|
||||||
<List
|
<div style={{ marginBottom: 16 }}>
|
||||||
size="large"
|
<Search
|
||||||
rowKey="id"
|
addonBefore="全文搜索"
|
||||||
loading={loading}
|
placeholder="在所有时间线中搜索内容..."
|
||||||
pagination={paginationProps}
|
onSearch={(value) => {
|
||||||
dataSource={list}
|
if (value) {
|
||||||
renderItem={(item: StoryType) => (
|
setSearchPagination({ ...searchPagination, current: 1, keyword: value });
|
||||||
<List.Item
|
searchRun({ keyword: value, pageNum: 1, pageSize: 10 });
|
||||||
actions={[
|
} else {
|
||||||
<a
|
setIsSearching(false);
|
||||||
key="edit"
|
setSearchResults([]);
|
||||||
disabled={!judgePermission(item?.permissionType, 'edit')}
|
}
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.preventDefault();
|
/>
|
||||||
showEditModal(item);
|
</div>
|
||||||
}}
|
{isSearching ? (
|
||||||
>
|
<div>
|
||||||
编辑
|
<Button onClick={() => setIsSearching(false)} style={{ marginBottom: 16 }}>
|
||||||
</a>,
|
返回故事列表
|
||||||
// 增加授权操作,可以授权给其他用户
|
</Button>
|
||||||
<a
|
<List
|
||||||
key="authorize"
|
size="large"
|
||||||
disabled={!judgePermission(item?.permissionType, 'auth')}
|
rowKey="id"
|
||||||
onClick={(e) => {
|
loading={searchLoading}
|
||||||
e.preventDefault();
|
dataSource={searchResults}
|
||||||
setCurrent(item);
|
pagination={{
|
||||||
setAuthorizeModelOpen(true);
|
...searchPagination,
|
||||||
}}
|
onChange: (page, pageSize) => {
|
||||||
>
|
history.push(`/story?keyword=${searchPagination.keyword}&page=${page}`);
|
||||||
授权
|
},
|
||||||
</a>,
|
}}
|
||||||
<a
|
renderItem={(item: StoryItem) => (
|
||||||
key="delete"
|
<List.Item>
|
||||||
disabled={!judgePermission(item?.permissionType, 'delete')}
|
<List.Item.Meta
|
||||||
onClick={(e) => {
|
title={
|
||||||
e.preventDefault();
|
<a onClick={() => history.push(`/story/${item.storyInstanceId}`)}>
|
||||||
deleteItem(item.instanceId ?? '');
|
<Highlight text={item.title} keyword={searchPagination.keyword || ''} />
|
||||||
}}
|
</a>
|
||||||
>
|
}
|
||||||
删除
|
description={<Highlight text={item.content} keyword={searchPagination.keyword || ''} />}
|
||||||
</a>,
|
/>
|
||||||
]}
|
</List.Item>
|
||||||
>
|
)}
|
||||||
<List.Item.Meta
|
/>
|
||||||
avatar={<Avatar src={item.logo} shape="square" size="large" />}
|
</div>
|
||||||
title={
|
) : (
|
||||||
|
<List
|
||||||
|
size="large"
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={paginationProps}
|
||||||
|
dataSource={list}
|
||||||
|
renderItem={(item: StoryType) => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
<a
|
<a
|
||||||
onClick={() => {
|
key="edit"
|
||||||
history.push(`/timeline/${item.instanceId}`);
|
disabled={!judgePermission(item?.permissionType, 'edit')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showEditModal(item);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.title}
|
编辑
|
||||||
</a>
|
</a>,
|
||||||
}
|
// 增加授权操作,可以授权给其他用户
|
||||||
description={item.description}
|
<a
|
||||||
/>
|
key="authorize"
|
||||||
<ListContent data={item} />
|
disabled={!judgePermission(item?.permissionType, 'auth')}
|
||||||
</List.Item>
|
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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -88,12 +88,19 @@ export async function queryStoryItemImages(itemId: string): Promise<{ data: stri
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export async function removeStoryItem(itemId: string): Promise<CommonResponse<string>> {
|
export async function removeStoryItem(instanceId: string) {
|
||||||
return request(`/story/item/${itemId}`, {
|
return request(`/api/story/item/${instanceId}`, {
|
||||||
method: 'DELETE',
|
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> {
|
export async function fetchImage(imageInstanceId: string): Promise<any> {
|
||||||
return request(`/file/image/${imageInstanceId}`, {
|
return request(`/file/image/${imageInstanceId}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|||||||
@@ -55,3 +55,22 @@ export async function uploadImage(params: FormData) {
|
|||||||
data: params,
|
data: params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUploadUrl(fileName: string) {
|
||||||
|
return request<CommonResponse<string>>(`/file/get-upload-url/${fileName}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideoUrl(instanceId: string): Promise<CommonResponse<string>> {
|
||||||
|
return request<CommonResponse<string>>(`/file/get-video-url/${instanceId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFileMetadata(data: any) {
|
||||||
|
return request<CommonResponse<string>>('/file/uploaded', {
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
20
src/types.ts
Normal file
20
src/types.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
export enum NotificationType {
|
||||||
|
FRIEND_REQUEST = 'FRIEND_REQUEST',
|
||||||
|
FRIEND_ACCEPTED = 'FRIEND_ACCEPTED',
|
||||||
|
NEW_COMMENT = 'NEW_COMMENT',
|
||||||
|
NEW_LIKE = 'NEW_LIKE',
|
||||||
|
SYSTEM = 'SYSTEM',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: number;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
senderAvatar: string;
|
||||||
|
type: NotificationType;
|
||||||
|
content: string;
|
||||||
|
targetId: string;
|
||||||
|
targetType: string;
|
||||||
|
createTime: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
@@ -99,3 +99,25 @@ export function parseBytes(sizeStr: string): number {
|
|||||||
|
|
||||||
return NaN;
|
return NaN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化视频时长
|
||||||
|
* 将秒数转换为 HH:MM:SS 或 MM:SS 格式
|
||||||
|
*
|
||||||
|
* @param seconds - 秒数
|
||||||
|
* @returns 格式化后的时间字符串
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds?: number): string {
|
||||||
|
if (!seconds) return '';
|
||||||
|
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
if (h > 0) {
|
||||||
|
return `${h}:${pad(m)}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
return `${m}:${pad(s)}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user