diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..a7b6a0d --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,107 @@ +{ + "name": "Timeline - 时间线记录", + "short_name": "Timeline", + "description": "记录生活中的每一个精彩时刻", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1890ff", + "orientation": "portrait-primary", + "scope": "/", + "lang": "zh-CN", + "icons": [ + { + "src": "/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ], + "screenshots": [ + { + "src": "/screenshots/home.png", + "sizes": "1080x1920", + "type": "image/png", + "form_factor": "narrow", + "label": "首页" + }, + { + "src": "/screenshots/timeline.png", + "sizes": "1080x1920", + "type": "image/png", + "form_factor": "narrow", + "label": "时间线" + } + ], + "shortcuts": [ + { + "name": "创建时刻", + "short_name": "创建", + "description": "快速创建新的时间线时刻", + "url": "/story/create", + "icons": [ + { + "src": "/icons/add-96x96.png", + "sizes": "96x96" + } + ] + }, + { + "name": "我的时间线", + "short_name": "时间线", + "description": "查看我的时间线", + "url": "/story", + "icons": [ + { + "src": "/icons/timeline-96x96.png", + "sizes": "96x96" + } + ] + } + ], + "categories": ["lifestyle", "productivity", "social"], + "prefer_related_applications": false, + "related_applications": [] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4fbf349 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,247 @@ +/** + * Service Worker - PWA 离线缓存 + * + * 功能描述: + * 提供 PWA 离线访问能力,缓存关键资源。 + * + * 缓存策略: + * - 静态资源:Cache First + * - API 请求:Network First + * - 图片资源:Stale While Revalidate + * + * @author Timeline Team + * @date 2024 + */ + +const CACHE_NAME = 'timeline-v1'; +const STATIC_CACHE = 'timeline-static-v1'; +const API_CACHE = 'timeline-api-v1'; +const IMAGE_CACHE = 'timeline-image-v1'; + +// 需要缓存的静态资源 +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png', +]; + +// 安装事件 +self.addEventListener('install', (event) => { + console.log('[ServiceWorker] 安装'); + + event.waitUntil( + caches.open(STATIC_CACHE).then((cache) => { + console.log('[ServiceWorker] 缓存静态资源'); + return cache.addAll(STATIC_ASSETS); + }) + ); + + // 立即激活 + self.skipWaiting(); +}); + +// 激活事件 +self.addEventListener('activate', (event) => { + console.log('[ServiceWorker] 激活'); + + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + // 删除旧版本缓存 + if (cacheName !== STATIC_CACHE && + cacheName !== API_CACHE && + cacheName !== IMAGE_CACHE) { + console.log('[ServiceWorker] 删除旧缓存:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + ); + + // 立即控制所有页面 + self.clients.claim(); +}); + +// 请求拦截 +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // 只处理同源请求 + if (url.origin !== location.origin) { + return; + } + + // 根据请求类型选择缓存策略 + if (isApiRequest(url)) { + // API 请求:Network First + event.respondWith(networkFirst(request, API_CACHE)); + } else if (isImageRequest(request)) { + // 图片请求:Stale While Revalidate + event.respondWith(staleWhileRevalidate(request, IMAGE_CACHE)); + } else { + // 静态资源:Cache First + event.respondWith(cacheFirst(request, STATIC_CACHE)); + } +}); + +/** + * 判断是否为 API 请求 + */ +function isApiRequest(url) { + return url.pathname.startsWith('/api/') || + url.pathname.startsWith('/story/') || + url.pathname.startsWith('/file/') || + url.pathname.startsWith('/user/'); +} + +/** + * 判断是否为图片请求 + */ +function isImageRequest(request) { + return request.destination === 'image' || + request.url.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i); +} + +/** + * Cache First 策略 + * 优先使用缓存,缓存不存在时请求网络 + */ +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + if (cached) { + return cached; + } + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // 网络请求失败,返回离线页面 + if (request.mode === 'navigate') { + return caches.match('/index.html'); + } + throw error; + } +} + +/** + * Network First 策略 + * 优先请求网络,失败时使用缓存 + */ +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + const cached = await cache.match(request); + if (cached) { + return cached; + } + throw error; + } +} + +/** + * Stale While Revalidate 策略 + * 立即返回缓存,同时后台更新 + */ +async function staleWhileRevalidate(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + + // 后台更新缓存 + const fetchPromise = fetch(request).then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }); + + // 返回缓存或等待网络响应 + return cached || fetchPromise; +} + +// 推送通知 +self.addEventListener('push', (event) => { + console.log('[ServiceWorker] 收到推送'); + + const data = event.data ? event.data.json() : {}; + + const options = { + body: data.body || '您有新的消息', + icon: '/icons/icon-192x192.png', + badge: '/icons/badge-72x72.png', + vibrate: [100, 50, 100], + data: { + url: data.url || '/' + }, + actions: [ + { action: 'open', title: '查看' }, + { action: 'close', title: '关闭' } + ] + }; + + event.waitUntil( + self.registration.showNotification(data.title || 'Timeline', options) + ); +}); + +// 通知点击 +self.addEventListener('notificationclick', (event) => { + console.log('[ServiceWorker] 通知点击'); + + event.notification.close(); + + if (event.action === 'open' || !event.action) { + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); + } +}); + +// 后台同步 +self.addEventListener('sync', (event) => { + console.log('[ServiceWorker] 后台同步:', event.tag); + + if (event.tag === 'sync-timeline') { + event.waitUntil(syncTimeline()); + } +}); + +/** + * 同步时间线数据 + */ +async function syncTimeline() { + // 获取待同步的数据 + const pendingData = await getPendingData(); + + for (const item of pendingData) { + try { + await fetch('/api/story/item', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item) + }); + + // 同步成功,删除待同步记录 + await removePendingData(item.id); + } catch (error) { + console.error('[ServiceWorker] 同步失败:', error); + } + } +} diff --git a/src/components/RightContent/AvatarDropdown.tsx b/src/components/RightContent/AvatarDropdown.tsx index 89f4a2e..ea14718 100644 --- a/src/components/RightContent/AvatarDropdown.tsx +++ b/src/components/RightContent/AvatarDropdown.tsx @@ -1,13 +1,11 @@ -import { outLogin } from '@/services/ant-design-pro/api'; +import { logoutUser } from '@/services/user/api'; import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons'; import { history, useModel } from '@umijs/max'; -import { Spin, message } from 'antd'; +import { message, Spin } from 'antd'; import { createStyles } from 'antd-style'; -import { stringify } from 'querystring'; import React, { useCallback } from 'react'; import { flushSync } from 'react-dom'; import HeaderDropdown from '../HeaderDropdown'; -import { logoutUser } from '@/services/user/api'; export type GlobalHeaderRightProps = { menu?: boolean; @@ -55,12 +53,9 @@ export const AvatarDropdown: React.FC = ({ menu, childre /** 此方法会跳转到 redirect 参数所在的位置 */ const redirect = urlParams.get('redirect'); // Note: There may be security issues, please note - if (window.location.pathname !== '/user/login' && !redirect) { + if (window.location.pathname !== '/user/login') { history.replace({ pathname: '/user/login', - search: stringify({ - redirect: pathname + search, - }), }); } }; diff --git a/src/hooks/useImageCompression.ts b/src/hooks/useImageCompression.ts new file mode 100644 index 0000000..84d371d --- /dev/null +++ b/src/hooks/useImageCompression.ts @@ -0,0 +1,319 @@ +/** + * useImageCompression - 图片压缩 Hook + * + * 功能描述: + * 提供图片压缩功能,在上传前对图片进行压缩处理。 + * 支持自定义压缩质量、最大尺寸等参数。 + * + * 功能特性: + * - 支持多种图片格式 (JPEG, PNG, WebP) + * - 可配置压缩质量 + * - 可配置最大宽高 + * - 支持批量压缩 + * - 返回压缩前后的文件大小对比 + * + * 设计思路: + * 使用 Canvas API 进行图片压缩: + * 1. 读取图片文件 + * 2. 创建 Canvas 并绘制图片 + * 3. 导出为指定质量和格式的 Blob + * 4. 返回压缩后的 File 对象 + * + * @author Timeline Team + * @date 2024 + */ + +import { useState, useCallback } from 'react'; + +/** + * 压缩配置接口 + * @property quality - 压缩质量 (0-1),默认 0.8 + * @property maxWidth - 最大宽度,默认 1920 + * @property maxHeight - 最大高度,默认 1080 + * @property mimeType - 输出格式,默认 'image/jpeg' + * @property convertToWebP - 是否转换为 WebP 格式 + */ +interface CompressionOptions { + quality?: number; + maxWidth?: number; + maxHeight?: number; + mimeType?: 'image/jpeg' | 'image/png' | 'image/webp'; + convertToWebP?: boolean; +} + +/** + * 压缩结果接口 + * @property file - 压缩后的文件 + * @property originalSize - 原始文件大小 (bytes) + * @property compressedSize - 压缩后文件大小 (bytes) + * @property compressionRatio - 压缩比率 + * @property originalDimensions - 原始尺寸 + * @property compressedDimensions - 压缩后尺寸 + */ +interface CompressionResult { + file: File; + originalSize: number; + compressedSize: number; + compressionRatio: number; + originalDimensions: { width: number; height: number }; + compressedDimensions: { width: number; height: number }; +} + +/** + * Hook 返回值接口 + */ +interface ImageCompressionHook { + /** 压缩单个图片 */ + compress: (file: File, options?: CompressionOptions) => Promise; + /** 批量压缩图片 */ + compressMultiple: (files: File[], options?: CompressionOptions) => Promise; + /** 是否正在压缩 */ + compressing: boolean; + /** 压缩进度 (0-100) */ + progress: number; + /** 压缩错误信息 */ + error: string | null; +} + +/** + * 默认压缩配置 + */ +const DEFAULT_OPTIONS: Required = { + quality: 0.8, + maxWidth: 1920, + maxHeight: 1080, + mimeType: 'image/jpeg', + convertToWebP: false, +}; + +/** + * 检查浏览器是否支持 WebP + */ +const supportsWebP = (): boolean => { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + return canvas.toDataURL('image/webp').startsWith('data:image/webp'); +}; + +/** + * 计算压缩后的尺寸 + * 保持宽高比,不超过最大尺寸 + */ +const calculateDimensions = ( + originalWidth: number, + originalHeight: number, + maxWidth: number, + maxHeight: number +): { width: number; height: number } => { + let width = originalWidth; + let height = originalHeight; + + // 如果宽度超过最大值 + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + + // 如果高度超过最大值 + if (height > maxHeight) { + width = Math.round((width * maxHeight) / height); + height = maxHeight; + } + + return { width, height }; +}; + +/** + * useImageCompression Hook + * 提供图片压缩功能 + * + * @returns 压缩方法和状态 + * + * @example + * const { compress, compressing, progress } = useImageCompression(); + * + * const handleUpload = async (file: File) => { + * const result = await compress(file, { quality: 0.7, maxWidth: 1200 }); + * console.log(`压缩比率: ${result.compressionRatio}%`); + * // 上传 result.file + * }; + */ +function useImageCompression(): ImageCompressionHook { + const [compressing, setCompressing] = useState(false); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + + /** + * 压缩单个图片 + * + * 实现步骤: + * 1. 读取图片文件 + * 2. 创建 Image 对象获取尺寸 + * 3. 计算压缩后尺寸 + * 4. 使用 Canvas 绘制并导出 + * 5. 返回压缩结果 + */ + const compress = useCallback( + async (file: File, options?: CompressionOptions): Promise => { + const config = { ...DEFAULT_OPTIONS, ...options }; + + // 如果启用了 WebP 转换且浏览器支持,则使用 WebP 格式 + const outputMimeType = config.convertToWebP && supportsWebP() + ? 'image/webp' + : config.mimeType; + + return new Promise((resolve, reject) => { + setCompressing(true); + setProgress(0); + setError(null); + + // 检查文件类型 + if (!file.type.startsWith('image/')) { + const err = '文件不是图片类型'; + setError(err); + setCompressing(false); + reject(new Error(err)); + return; + } + + setProgress(10); + + // 创建 FileReader 读取文件 + const reader = new FileReader(); + reader.onload = (e) => { + setProgress(20); + + // 创建 Image 对象 + const img = new Image(); + img.onload = () => { + setProgress(40); + + // 获取原始尺寸 + const originalWidth = img.width; + const originalHeight = img.height; + + // 计算压缩后尺寸 + const { width, height } = calculateDimensions( + originalWidth, + originalHeight, + config.maxWidth, + config.maxHeight + ); + + setProgress(50); + + // 创建 Canvas + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + const err = '无法创建 Canvas 上下文'; + setError(err); + setCompressing(false); + reject(new Error(err)); + return; + } + + // 绘制图片 + ctx.drawImage(img, 0, 0, width, height); + + setProgress(70); + + // 导出为 Blob + canvas.toBlob( + (blob) => { + if (!blob) { + const err = '图片压缩失败'; + setError(err); + setCompressing(false); + reject(new Error(err)); + return; + } + + setProgress(90); + + // 创建新的 File 对象 + const fileName = file.name.replace(/\.[^.]+$/, '') + + (outputMimeType === 'image/webp' ? '.webp' : '.jpg'); + const compressedFile = new File([blob], fileName, { + type: outputMimeType, + lastModified: Date.now(), + }); + + setProgress(100); + setCompressing(false); + + // 返回压缩结果 + resolve({ + file: compressedFile, + originalSize: file.size, + compressedSize: compressedFile.size, + compressionRatio: Math.round( + ((file.size - compressedFile.size) / file.size) * 100 + ), + originalDimensions: { width: originalWidth, height: originalHeight }, + compressedDimensions: { width, height }, + }); + }, + outputMimeType, + config.quality + ); + }; + + img.onerror = () => { + const err = '图片加载失败'; + setError(err); + setCompressing(false); + reject(new Error(err)); + }; + + img.src = e.target?.result as string; + }; + + reader.onerror = () => { + const err = '文件读取失败'; + setError(err); + setCompressing(false); + reject(new Error(err)); + }; + + reader.readAsDataURL(file); + }); + }, + [] + ); + + /** + * 批量压缩图片 + * + * 实现思路: + * 逐个处理图片文件,更新总体进度 + */ + const compressMultiple = useCallback( + async (files: File[], options?: CompressionOptions): Promise => { + const results: CompressionResult[] = []; + + for (let i = 0; i < files.length; i++) { + const result = await compress(files[i], options); + results.push(result); + setProgress(Math.round(((i + 1) / files.length) * 100)); + } + + return results; + }, + [compress] + ); + + return { + compress, + compressMultiple, + compressing, + progress, + error, + }; +} + +export default useImageCompression; diff --git a/src/pages/account/center/index.tsx b/src/pages/account/center/index.tsx index 96eab6e..a23edc4 100644 --- a/src/pages/account/center/index.tsx +++ b/src/pages/account/center/index.tsx @@ -1,9 +1,9 @@ -import { ClusterOutlined, ContactsOutlined, HomeOutlined, PlusOutlined, UserAddOutlined, UserDeleteOutlined, MessageOutlined } from '@ant-design/icons'; +import { HomeOutlined, PlusOutlined } from '@ant-design/icons'; import { GridContent } from '@ant-design/pro-components'; import { useRequest } from '@umijs/max'; -import { Avatar, Card, Col, Divider, Input, InputRef, Row, Tag, List, Button, Space, Popconfirm, message } from 'antd'; +import { Avatar, Card, Col, Divider, Input, InputRef, Row, Tag } from 'antd'; import type { FC } from 'react'; -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import useStyles from './Center.style'; import type { CurrentUser, tabKeyType, TagType } from './data.d'; import { queryCurrent } from './service'; @@ -11,6 +11,8 @@ import Dynamic from './components/Dynamic'; import Friends from './components/Friends'; import Messages from './components/Message'; import useWebSocket from '@/components/Hooks/useWebSocket'; +import { useIsMobile } from '@/hooks/useIsMobile'; + const operationTabList = [ { key: 'dynamic', @@ -128,11 +130,14 @@ const TagList: FC<{ const Center: React.FC = () => { const { styles } = useStyles(); + const isMobile = useIsMobile(); const [tabKey, setTabKey] = useState('dynamic'); const { messages: wsMessages, sendChatMessage } = useWebSocket('/user-api/ws'); // 获取用户信息 - const { data: currentUser, loading } = useRequest(() => { + const { data: currentUser, loading, refresh } = useRequest(() => { return queryCurrent(); + }, { + pollingInterval: 5000, // Simple short polling for profile sync }); // 渲染用户信息 @@ -185,7 +190,7 @@ const Center: React.FC = () => { return ( - + { )} - + = ({ onUpload, uploading }) => { + const isMobile = useIsMobile(); + const beforeUpload = (file: UploadFile) => { // 允许上传图片和视频 const isImageOrVideo = file.type?.startsWith('image/') || file.type?.startsWith('video/'); @@ -46,26 +50,41 @@ const GalleryToolbar: FC = ({ return true; }; + const mobileMenu = ( + + + 批量操作 + + + onViewModeChange({ target: { value: 'small' } } as any)}>小图 + onViewModeChange({ target: { value: 'large' } } as any)}>大图 + onViewModeChange({ target: { value: 'list' } } as any)}>列表 + + + ); + return ( - + {batchMode ? ( <> - + ) : ( @@ -80,30 +99,40 @@ const GalleryToolbar: FC = ({ - + + {isMobile ? ( + + + + + 小图 + + + 大图 + + + 列表 + + 表格 + + + )} )} - - - 小图 - - - 大图 - - - 列表 - - 表格 - ); }; diff --git a/src/pages/gallery/components/GridView.tsx b/src/pages/gallery/components/GridView.tsx index 4709caf..3942d81 100644 --- a/src/pages/gallery/components/GridView.tsx +++ b/src/pages/gallery/components/GridView.tsx @@ -10,7 +10,8 @@ import { PlayCircleOutlined, } from '@ant-design/icons'; import { Button, Checkbox, Dropdown, Menu, Spin } from 'antd'; -import React, { FC, useCallback } from 'react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { useIsMobile } from '@/hooks/useIsMobile'; import '../index.css'; interface GridViewProps { @@ -38,7 +39,29 @@ const GridView: FC = ({ loadingMore, onScroll, }) => { + const isMobile = useIsMobile(); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + const getImageSize = useCallback(() => { + if (isMobile) { + // Mobile: 3 columns for small, 2 columns for large + const padding = 16; // page padding + const gap = 8; // grid gap + if (viewMode === 'large') { + const size = (windowWidth - padding * 2 - gap) / 2; + return { width: size, height: size }; + } + // default small + const size = (windowWidth - padding * 2 - gap * 2) / 3; + return { width: size, height: size }; + } + switch (viewMode) { case 'small': return { width: 150, height: 150 }; @@ -47,7 +70,7 @@ const GridView: FC = ({ default: return { width: 150, height: 150 }; } - }, [viewMode]); + }, [viewMode, isMobile, windowWidth]); const imageSize = getImageSize(); @@ -103,9 +126,19 @@ const GridView: FC = ({
{imageList.map((item: ImageItem, index: number) => ( -
+
{batchMode && ( = ({
= ({ onClick={() => !batchMode && onPreview(index)} > {(item.duration || item.thumbnailInstanceId) && ( - + )} {item.duration && ( = ({ {item.imageName}
-
diff --git a/src/pages/gallery/index.tsx b/src/pages/gallery/index.tsx index aac9f43..8204753 100644 --- a/src/pages/gallery/index.tsx +++ b/src/pages/gallery/index.tsx @@ -51,6 +51,7 @@ const Gallery: FC = () => { loadMore: true, refreshDeps: [page], ready: viewMode !== 'table', // 非表格模式才启用 + pollingInterval: 5000, // Sync gallery updates }, ); @@ -61,6 +62,7 @@ const Gallery: FC = () => { run: fetchTableData, } = useRequest(async (params) => await getImagesList(params), { manual: true, + pollingInterval: viewMode === 'table' ? 5000 : 0, // Sync when in table mode }); // 当视图模式改变时重置分页 diff --git a/src/pages/story/components/BatchOperationToolbar.tsx b/src/pages/story/components/BatchOperationToolbar.tsx new file mode 100644 index 0000000..9d80bef --- /dev/null +++ b/src/pages/story/components/BatchOperationToolbar.tsx @@ -0,0 +1,234 @@ +/** + * BatchOperationToolbar - 批量操作工具栏组件 + * + * 功能描述: + * 提供时间线节点的批量操作功能,包括: + * - 多选模式切换 + * - 全选/取消全选 + * - 批量删除 + * - 批量修改时间 + * - 批量移动到其他故事 + * + * 设计思路: + * 1. 使用受控模式,由父组件管理选中状态 + * 2. 支持移动端适配,工具栏固定在底部 + * 3. 操作前进行二次确认,防止误操作 + * + * @author Timeline Team + * @date 2024 + */ + +import { useIsMobile } from '@/hooks/useIsMobile'; +import { + DeleteOutlined, + ClockCircleOutlined, + ExportOutlined, + CloseOutlined, + CheckOutlined, +} from '@ant-design/icons'; +import { Button, Popconfirm, Space, message, Dropdown, MenuProps, Badge } from 'antd'; +import React, { memo, useState, useCallback } from 'react'; + +/** + * 组件属性接口 + * @property selectedCount - 已选中数量 + * @property totalCount - 总数量 + * @property onSelectAll - 全选回调 + * @property onCancel - 取消批量操作回调 + * @property onBatchDelete - 批量删除回调 + * @property onBatchChangeTime - 批量修改时间回调 + * @property onBatchMove - 批量移动回调 + * @property loading - 加载状态 + */ +interface BatchOperationToolbarProps { + selectedCount: number; + totalCount: number; + onSelectAll: () => void; + onCancel: () => void; + onBatchDelete: () => void; + onBatchChangeTime?: () => void; + onBatchMove?: () => void; + loading?: boolean; +} + +/** + * BatchOperationToolbar 组件 + * 提供时间线节点的批量操作功能 + */ +const BatchOperationToolbar: React.FC = memo(({ + selectedCount, + totalCount, + onSelectAll, + onCancel, + onBatchDelete, + onBatchChangeTime, + onBatchMove, + loading = false, +}) => { + const isMobile = useIsMobile(); + const [deleteConfirmVisible, setDeleteConfirmVisible] = useState(false); + + /** + * 处理批量删除 + * 显示确认对话框,确认后执行删除 + */ + const handleBatchDelete = useCallback(() => { + if (selectedCount === 0) { + message.warning('请先选择要删除的节点'); + return; + } + onBatchDelete(); + setDeleteConfirmVisible(false); + }, [selectedCount, onBatchDelete]); + + /** + * 更多操作菜单项 + * 用于移动端或空间受限时展示次要操作 + */ + const moreMenuItems: MenuProps['items'] = [ + { + key: 'changeTime', + label: '修改时间', + icon: , + onClick: onBatchChangeTime, + disabled: selectedCount === 0 || !onBatchChangeTime, + }, + { + key: 'move', + label: '移动到...', + icon: , + onClick: onBatchMove, + disabled: selectedCount === 0 || !onBatchMove, + }, + ]; + + // 移动端样式 + const mobileStyle: React.CSSProperties = { + position: 'fixed', + bottom: 56, // 底部导航栏高度 + left: 0, + right: 0, + padding: '12px 16px', + background: '#fff', + boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.1)', + zIndex: 100, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }; + + // 桌面端样式 + const desktopStyle: React.CSSProperties = { + padding: '12px 16px', + background: '#f5f5f5', + borderRadius: 8, + marginBottom: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }; + + return ( +
+ {/* 左侧:选择状态 */} + + + + 已选择 {selectedCount} / {totalCount} 项 + + {!isMobile && ( + + )} + + + {/* 右侧:操作按钮 */} + + {isMobile ? ( + // 移动端:精简按钮 + <> + + + + + + + ) : ( + // 桌面端:完整按钮 + <> + + + + {onBatchChangeTime && ( + + )} + {onBatchMove && ( + + )} + + + )} + +
+ ); +}); + +BatchOperationToolbar.displayName = 'BatchOperationToolbar'; + +export default BatchOperationToolbar; diff --git a/src/pages/story/components/CollaboratorModal.tsx b/src/pages/story/components/CollaboratorModal.tsx new file mode 100644 index 0000000..c092e77 --- /dev/null +++ b/src/pages/story/components/CollaboratorModal.tsx @@ -0,0 +1,199 @@ +import { useIsMobile } from '@/hooks/useIsMobile'; +import { useRequest } from '@umijs/max'; +import { Button, Drawer, Input, List, message, Modal, Popconfirm, Select, Space, Tag } from 'antd'; +import React, { useEffect, useState } from 'react'; +import { getStoryPermissions, inviteUser, removePermission, updatePermission } from '../service'; + +interface CollaboratorModalProps { + visible: boolean; + onCancel: () => void; + storyId: string; +} + +const PermissionTypeMap: Record = { + 1: '创建者', + 2: '管理员', + 3: '编辑者', + 4: '仅查看', +}; + +const InviteStatusMap: Record = { + 0: { text: '待处理', color: 'orange' }, + 1: { text: '已接受', color: 'green' }, + 2: { text: '已拒绝', color: 'red' }, +}; + +const CollaboratorModal: React.FC = ({ visible, onCancel, storyId }) => { + const isMobile = useIsMobile(); + const [inviteUserId, setInviteUserId] = useState(''); + const [invitePermissionType, setInvitePermissionType] = useState(4); + + const { + data: permissions, + run: fetchPermissions, + loading, + } = useRequest(() => getStoryPermissions(storyId), { + manual: true, + formatResult: (res: any) => res?.data || [], + }); + + useEffect(() => { + if (visible && storyId) { + fetchPermissions(); + } + }, [visible, storyId]); + + const handleInvite = async () => { + if (!inviteUserId) return; + try { + await inviteUser({ + userId: inviteUserId, + storyInstanceId: storyId, + permissionType: invitePermissionType, + }); + message.success('邀请已发送'); + setInviteUserId(''); + fetchPermissions(); + } catch (error) { + message.error('邀请失败'); + } + }; + + const handleUpdatePermission = async (permissionId: string, type: number) => { + try { + await updatePermission({ permissionId, permissionType: type }); + message.success('权限已更新'); + fetchPermissions(); + } catch (error) { + message.error('更新失败'); + } + }; + + const handleRemove = async (permissionId: string) => { + try { + await removePermission(permissionId); + message.success('已移除'); + fetchPermissions(); + } catch (error) { + message.error('移除失败'); + } + }; + + const content = ( + <> +
+ setInviteUserId(e.target.value)} + style={{ flex: 1 }} + /> +
+ handleUpdatePermission(item.permissionId, val)} + style={{ width: 100 }} + size="small" + options={[ + { label: '管理员', value: 2 }, + { label: '编辑者', value: 3 }, + { label: '仅查看', value: 4 }, + ]} + disabled={item.inviteStatus === 2} // Rejected + /> + handleRemove(item.permissionId)}> + + +
+ ), + ]} + > + + 用户ID: {item.userId} +
+ } + description={ + + {PermissionTypeMap[item.permissionType]} + {item.inviteStatus !== undefined && InviteStatusMap[item.inviteStatus] && ( + + {InviteStatusMap[item.inviteStatus].text} + + )} + + } + /> + + )} + /> + + ); + + if (isMobile) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +}; + +export default CollaboratorModal; diff --git a/src/pages/story/components/SortableTimelineGrid.tsx b/src/pages/story/components/SortableTimelineGrid.tsx new file mode 100644 index 0000000..212db9f --- /dev/null +++ b/src/pages/story/components/SortableTimelineGrid.tsx @@ -0,0 +1,288 @@ +/** + * SortableTimelineGrid - 可拖拽排序的时间线网格组件 + * + * 功能描述: + * 该组件基于 dnd-kit 库实现时间线节点的拖拽排序功能。 + * 支持在同一日期分组内拖拽调整节点顺序,并实时保存排序结果。 + * + * 设计思路: + * 1. 使用 DndContext 作为拖拽上下文容器 + * 2. 使用 SortableContext 管理可排序项 + * 3. 每个时间线节点包装为 SortableItem 实现独立拖拽 + * 4. 拖拽结束后调用后端 API 更新排序 + * + * @author Timeline Team + * @date 2024 + */ + +import { useIsMobile } from '@/hooks/useIsMobile'; +import { StoryItem } from '@/pages/story/data'; +import { updateStoryItemOrder } from '@/pages/story/service'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragStartEvent, + DragOverlay, + MeasuringStrategy, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + rectSortingStrategy, +} from '@dnd-kit/sortable'; +import { message, Spin } from 'antd'; +import React, { useState, useCallback, useMemo, memo } from 'react'; +import TimelineGridItem from './TimelineGridItem'; +import SortableTimelineGridItem from './SortableTimelineGridItem'; + +/** + * 组件属性接口 + * @property items - 时间线节点数组 + * @property dateKey - 日期分组键(用于标识分组) + * @property sortValue - 排序值(用于分组排序) + * @property handleOption - 操作回调函数 + * @property onOpenDetail - 打开详情回调 + * @property refresh - 刷新数据回调 + * @property disableEdit - 是否禁用编辑 + * @property onOrderChange - 排序变化回调(可选) + */ +interface SortableTimelineGridProps { + items: StoryItem[]; + dateKey: string; + sortValue: number; + handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void; + onOpenDetail: (item: StoryItem) => void; + refresh: () => void; + disableEdit?: boolean; + onOrderChange?: (dateKey: string, newItems: StoryItem[]) => void; +} + +/** + * SortableTimelineGrid 组件 + * 实现时间线节点的拖拽排序功能 + */ +const SortableTimelineGrid: React.FC = memo(({ + items, + dateKey, + sortValue, + handleOption, + onOpenDetail, + refresh, + disableEdit = false, + onOrderChange, +}) => { + const isMobile = useIsMobile(); + + // 当前拖拽中的节点ID + const [activeId, setActiveId] = useState(null); + + // 本地排序状态(用于即时UI更新) + const [localItems, setLocalItems] = useState(items); + + // 保存中状态 + const [saving, setSaving] = useState(false); + + // 同步外部 items 变化 + React.useEffect(() => { + setLocalItems(items); + }, [items]); + + /** + * 配置拖拽传感器 + * - PointerSensor: 鼠标/触摸拖拽 + * - KeyboardSensor: 键盘拖拽(无障碍支持) + * + * 注意:需要设置 activationConstraint 防止误触发拖拽 + */ + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 移动8px后才开始拖拽,避免误触 + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + /** + * 获取当前拖拽中的节点 + */ + const activeItem = useMemo(() => { + if (!activeId) return null; + return localItems.find(item => item.instanceId === activeId); + }, [activeId, localItems]); + + /** + * 拖拽开始处理 + * 记录当前拖拽节点ID,用于显示 DragOverlay + */ + const handleDragStart = useCallback((event: DragStartEvent) => { + const { active } = event; + setActiveId(active.id as string); + }, []); + + /** + * 拖拽结束处理 + * 核心逻辑: + * 1. 计算新的排序顺序 + * 2. 更新本地状态(即时UI反馈) + * 3. 调用后端API保存排序 + * 4. 通知父组件排序变化 + */ + const handleDragEnd = useCallback(async (event: DragEndEvent) => { + const { active, over } = event; + + // 清除拖拽状态 + setActiveId(null); + + // 如果没有放置目标,或放置在原位置,不做处理 + if (!over || active.id === over.id) { + return; + } + + // 查找拖拽节点在数组中的位置 + const oldIndex = localItems.findIndex(item => item.instanceId === active.id); + const newIndex = localItems.findIndex(item => item.instanceId === over.id); + + // 如果位置没变,不做处理 + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + + // 计算新的排序数组 + const newItems = arrayMove(localItems, oldIndex, newIndex); + + // 即时更新本地状态(提升用户体验) + setLocalItems(newItems); + + // 调用后端API保存排序 + try { + setSaving(true); + + // 构建排序数据:节点ID -> 新排序值 + const orderData = newItems.map((item, index) => ({ + instanceId: item.instanceId, + sortOrder: index, + })); + + const response = await updateStoryItemOrder(orderData); + + if (response.code === 200) { + message.success('排序已保存'); + // 通知父组件 + onOrderChange?.(dateKey, newItems); + } else { + // 保存失败,恢复原顺序 + setLocalItems(items); + message.error('排序保存失败'); + } + } catch (error) { + // 异常处理,恢复原顺序 + console.error('保存排序失败:', error); + setLocalItems(items); + message.error('排序保存失败,请重试'); + } finally { + setSaving(false); + } + }, [localItems, items, dateKey, onOrderChange]); + + /** + * 拖拽取消处理 + */ + const handleDragCancel = useCallback(() => { + setActiveId(null); + }, []); + + // 生成可排序项的ID列表 + const itemIds = useMemo(() => + localItems.map(item => item.instanceId), + [localItems] + ); + + return ( +
+ {/* 保存中遮罩 */} + {saving && ( +
+ +
+ )} + + {/* 拖拽上下文容器 */} + + {/* 可排序上下文 - 使用矩形排序策略 */} + + {localItems.map((item) => ( + + ))} + + + {/* 拖拽覆盖层 - 显示拖拽中的节点预览 */} + + {activeItem ? ( +
+ +
+ ) : null} +
+
+
+ ); +}); + +SortableTimelineGrid.displayName = 'SortableTimelineGrid'; + +export default SortableTimelineGrid; diff --git a/src/pages/story/components/SortableTimelineGridItem.tsx b/src/pages/story/components/SortableTimelineGridItem.tsx new file mode 100644 index 0000000..8dfbe57 --- /dev/null +++ b/src/pages/story/components/SortableTimelineGridItem.tsx @@ -0,0 +1,159 @@ +/** + * SortableTimelineGridItem - 可排序的时间线网格项组件 + * + * 功能描述: + * 该组件包装 TimelineGridItem,为其添加拖拽排序能力。 + * 使用 dnd-kit 的 useSortable Hook 实现拖拽功能。 + * + * 设计思路: + * 1. 使用 useSortable Hook 获取拖拽属性 + * 2. 通过 CSS transform 实现拖拽动画 + * 3. 拖拽时显示占位符和视觉反馈 + * 4. 支持禁用拖拽(移动端默认禁用) + * + * @author Timeline Team + * @date 2024 + */ + +import { StoryItem } from '@/pages/story/data'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { DragOutlined } from '@ant-design/icons'; +import React, { memo, CSSProperties } from 'react'; +import TimelineGridItem from './TimelineGridItem'; + +/** + * 组件属性接口 + * @property item - 时间线节点数据 + * @property disabled - 是否禁用拖拽 + * @property handleOption - 操作回调 + * @property onOpenDetail - 打开详情回调 + * @property refresh - 刷新数据回调 + * @property disableEdit - 是否禁用编辑 + */ +interface SortableTimelineGridItemProps { + item: StoryItem; + disabled?: boolean; + handleOption: (item: StoryItem, option: 'add' | 'edit' | 'addSubItem' | 'editSubItem') => void; + onOpenDetail: (item: StoryItem) => void; + refresh: () => void; + disableEdit?: boolean; +} + +/** + * SortableTimelineGridItem 组件 + * 为时间线节点添加拖拽排序能力 + */ +const SortableTimelineGridItem: React.FC = memo(({ + item, + disabled = false, + handleOption, + onOpenDetail, + refresh, + disableEdit = false, +}) => { + /** + * useSortable Hook 核心功能: + * - attributes: 可访问性属性(如 tabindex) + * - listeners: 拖拽事件监听器 + * - setNodeRef: 设置 DOM 节点引用 + * - transform: CSS transform 值 + * - transition: CSS transition 值 + * - isDragging: 是否正在拖拽 + */ + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: item.instanceId, + disabled: disabled, + data: { + type: 'timeline-item', + item, + }, + }); + + /** + * 构建拖拽样式 + * - transform: 应用拖拽位移和缩放 + * - transition: 平滑过渡动画 + * - opacity: 拖拽时降低透明度 + * - zIndex: 拖拽时提升层级 + */ + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 'auto', + position: 'relative' as const, + }; + + return ( +
+ {/* 拖拽手柄 - 仅在非禁用状态显示 */} + {!disabled && !disableEdit && ( +
e.stopPropagation()} + > + +
+ )} + + {/* 原有组件 */} + + + {/* 拖拽时的视觉反馈 */} + {isDragging && ( +
+ )} +
+ ); +}); + +SortableTimelineGridItem.displayName = 'SortableTimelineGridItem'; + +export default SortableTimelineGridItem; diff --git a/src/pages/story/components/UndoRedoToolbar.tsx b/src/pages/story/components/UndoRedoToolbar.tsx new file mode 100644 index 0000000..7e5f055 --- /dev/null +++ b/src/pages/story/components/UndoRedoToolbar.tsx @@ -0,0 +1,145 @@ +/** + * UndoRedoToolbar - 撤销/重做工具栏组件 + * + * 功能描述: + * 提供撤销和重做操作的工具栏,支持按钮点击和键盘快捷键。 + * + * 功能特性: + * - 撤销/重做按钮,根据状态自动禁用 + * - 显示历史记录数量 + * - 支持移动端适配 + * - 支持历史记录面板(可选) + * + * @author Timeline Team + * @date 2024 + */ + +import { UndoOutlined, RedoOutlined, HistoryOutlined } from '@ant-design/icons'; +import { Button, Space, Tooltip, Dropdown, MenuProps, Badge } from 'antd'; +import React, { memo } from 'react'; + +/** + * 组件属性接口 + * @property canUndo - 是否可撤销 + * @property canRedo - 是否可重做 + * @property historyLength - 历史记录数量 + * @property futureLength - 重做记录数量 + * @property onUndo - 撤销回调 + * @property onRedo - 重做回调 + * @property getHistoryList - 获取历史记录列表 + * @property compact - 是否紧凑模式(移动端) + */ +interface UndoRedoToolbarProps { + canUndo: boolean; + canRedo: boolean; + historyLength: number; + futureLength: number; + onUndo: () => void; + onRedo: () => void; + getHistoryList?: () => Array<{ description: string; timestamp: number }>; + compact?: boolean; +} + +/** + * UndoRedoToolbar 组件 + * 提供撤销/重做操作界面 + */ +const UndoRedoToolbar: React.FC = memo(({ + canUndo, + canRedo, + historyLength, + futureLength, + onUndo, + onRedo, + getHistoryList, + compact = false, +}) => { + /** + * 历史记录菜单项 + * 用于展示最近的操作历史 + */ + const historyMenuItems: MenuProps['items'] = getHistoryList + ? getHistoryList() + .slice(-10) + .reverse() + .map((item, index) => ({ + key: index, + label: ( +
+ {item.description} + + {new Date(item.timestamp).toLocaleTimeString()} + +
+ ), + })) + : []; + + // 紧凑模式(移动端) + if (compact) { + return ( + + 0 ? ` - ${historyLength}步可用` : ''}`}> + + + + + + + + + + + {/* 历史记录下拉菜单 */} + {getHistoryList && historyMenuItems.length > 0 && ( + + + + )} + + ); +}); + +UndoRedoToolbar.displayName = 'UndoRedoToolbar'; + +export default UndoRedoToolbar; diff --git a/src/pages/story/detail.css b/src/pages/story/detail.css index e907ad4..4aaf61f 100644 --- a/src/pages/story/detail.css +++ b/src/pages/story/detail.css @@ -74,7 +74,9 @@ html[data-theme='dark'] { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; border-bottom: 2px solid var(--timeline-header-color); - transition: color 0.3s ease, border-color 0.3s ease; + transition: + color 0.3s ease, + border-color 0.3s ease; } /* Grid container for timeline items - 动态大小网格布局 */ @@ -206,9 +208,9 @@ html[data-theme='dark'] { font-weight: 600; font-size: 15px; line-height: 1.5; + transition: color 0.3s ease; -webkit-line-clamp: 2; -webkit-box-orient: vertical; - transition: color 0.3s ease; } /* Card description */ @@ -353,18 +355,18 @@ html[data-theme='dark'] { /* 更多图片指示器 */ .timeline-grid-item .more-images-indicator { - flex-shrink: 0; - width: 80px; - height: 80px; display: flex; + flex-shrink: 0; align-items: center; justify-content: center; - background: var(--timeline-more-bg); + width: 80px; + height: 80px; color: var(--timeline-more-color); - border-radius: 6px; - font-size: 12px; font-weight: 600; + font-size: 12px; + background: var(--timeline-more-bg); border: 1px solid var(--timeline-image-border); + border-radius: 6px; backdrop-filter: blur(4px); transition: all 0.3s ease; } @@ -388,6 +390,23 @@ html[data-theme='dark'] { font-size: 18px; } +/* Sortable Timeline Item Styles */ +.sortable-timeline-item-wrapper { + position: relative; +} + +.sortable-timeline-item-wrapper:hover .drag-handle { + opacity: 1 !important; +} + +.sortable-timeline-item-wrapper .drag-handle:hover { + background: rgba(0, 0, 0, 0.8) !important; +} + +.sortable-timeline-item-wrapper .drag-handle:active { + cursor: grabbing; +} + /* Loading and empty states */ .timeline .load-indicator, .timeline .no-more-data { @@ -474,7 +493,7 @@ html[data-theme='dark'] { width: 60px; height: 60px; } - + .timeline-grid-item .more-images-indicator { width: 60px; height: 60px; @@ -493,6 +512,14 @@ html[data-theme='dark'] { padding: 16px; } + /* 拓展图片展示:允许图片容器延伸到卡片边缘 */ + .timeline-grid-item .item-images-row { + margin-right: -16px; + margin-left: -16px; + padding-right: 16px; + padding-left: 16px; + } + .timeline-section-header { font-size: 20px; } @@ -510,7 +537,7 @@ html[data-theme='dark'] { width: 50px; height: 50px; } - + .timeline-grid-item .more-images-indicator { width: 50px; height: 50px; diff --git a/src/pages/story/detail.tsx b/src/pages/story/detail.tsx index 485a98b..05955c0 100644 --- a/src/pages/story/detail.tsx +++ b/src/pages/story/detail.tsx @@ -1,32 +1,19 @@ // src/pages/story/detail.tsx import { useIsMobile } from '@/hooks/useIsMobile'; import AddTimeLineItemModal from '@/pages/story/components/AddTimeLineItemModal'; -import TimelineGridItem from '@/pages/story/components/TimelineGridItem'; +import SortableTimelineGrid from '@/pages/story/components/SortableTimelineGrid'; import TimelineItemDrawer from '@/pages/story/components/TimelineItemDrawer'; import { StoryItem, StoryItemTimeQueryParams } from '@/pages/story/data'; import { queryStoryDetail, queryStoryItem, removeStoryItem } from '@/pages/story/service'; import { judgePermission } from '@/pages/story/utils/utils'; -import { PlusOutlined, SyncOutlined } from '@ant-design/icons'; +import { MoreOutlined, PlusOutlined, SyncOutlined, TeamOutlined } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-components'; import { history, useParams, useRequest } from '@umijs/max'; -import { Button, Empty, FloatButton, message, Spin } from 'antd'; +import { Button, Dropdown, Empty, FloatButton, MenuProps, message, Space, Spin } from 'antd'; import { PullToRefresh } from 'antd-mobile'; import { useCallback, useEffect, useRef, useState } from 'react'; import './detail.css'; - -// 格式化时间数组为易读格式 -const formatTimeArray = (time: string | number[] | undefined): string => { - if (!time) return ''; - - // 如果是数组格式 [2025, 12, 23, 8, 55, 39] - if (Array.isArray(time)) { - const [year, month, day, hour, minute, second] = time; - return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`; - } - - // 如果已经是字符串格式,直接返回 - return String(time); -}; +import CollaboratorModal from './components/CollaboratorModal'; const Index = () => { const isMobile = useIsMobile(); @@ -42,6 +29,7 @@ const Index = () => { 'add' | 'edit' | 'addSubItem' | 'editSubItem' >(); const [openDetailDrawer, setOpenDetailDrawer] = useState(false); + const [openCollaboratorModal, setOpenCollaboratorModal] = useState(false); const [detailItem, setDetailItem] = useState(); const [pagination, setPagination] = useState({ current: 1, pageSize: 30 }); const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据 @@ -264,13 +252,45 @@ const Index = () => { const groupedItems = groupItemsByDate(items); - return ( - history.push('/story')} - title={ - queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}` + const getExtraContent = () => { + if (isMobile) { + const menuItems: MenuProps['items'] = [ + { + key: 'refresh', + label: '刷新', + icon: , + onClick: () => { + setItems([]); + setPagination({ current: 1, pageSize: 30 }); + setLoadDirection('refresh'); + run({ current: 1 }); + }, + }, + ]; + + if (judgePermission(detail?.permissionType ?? null, 'auth')) { + menuItems.unshift({ + key: 'collaborators', + label: '协作成员', + icon: , + onClick: () => setOpenCollaboratorModal(true), + }); } - extra={ + + return ( + + + )} + + ); + }; + + return ( + history.push('/story')} + title={ + queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}` } + extra={getExtraContent()} >
{
)} {Object.values(groupedItems) - .sort((a, b) => b.sortValue - a.sortValue) // 按日期降序排列(最新的在前) - .map(({ dateKey, items: dateItems }) => ( + .sort((a, b) => b.sortValue - a.sortValue) + .map(({ dateKey, items: dateItems, sortValue }) => (

{dateKey}

-
- {dateItems.map((item, index) => { - // 调试:确保每个item都有有效的数据 - if (!item || (!item.id && !item.instanceId)) { - console.warn('发现无效的item:', item, 'at index:', index); - return null; // 不渲染无效的item - } - - return ( - { - setCurrentItem(item); - setCurrentOption(option); - setOpenAddItemModal(true); - }} - onOpenDetail={(item: StoryItem) => { - setDetailItem(item); - setOpenDetailDrawer(true); - }} - disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')} - refresh={() => { - setPagination((prev) => ({ ...prev, current: 1 })); - hasShownNoMoreOldRef.current = false; - hasShownNoMoreNewRef.current = false; - setLoadDirection('refresh'); - run({ current: 1 }); - queryDetail(); - }} - /> - ); - })} -
+ { + setCurrentItem(item); + setCurrentOption(option); + setOpenAddItemModal(true); + }} + onOpenDetail={(item: StoryItem) => { + setDetailItem(item); + setOpenDetailDrawer(true); + }} + disableEdit={!judgePermission(detail?.permissionType ?? null, 'edit')} + refresh={() => { + setPagination((prev) => ({ ...prev, current: 1 })); + hasShownNoMoreOldRef.current = false; + hasShownNoMoreNewRef.current = false; + setLoadDirection('refresh'); + run({ current: 1 }); + queryDetail(); + }} + onOrderChange={(changedDateKey, newItems) => { + setItems((prev) => { + const updated = [...prev]; + const startIdx = updated.findIndex( + (item) => item.storyItemTime === newItems[0]?.storyItemTime + ); + if (startIdx !== -1) { + updated.splice(startIdx, newItems.length, ...newItems); + } + return updated; + }); + }} + />
))} {loading &&
加载中...
} @@ -498,6 +529,11 @@ const Index = () => { }} /> )} + setOpenCollaboratorModal(false)} + storyId={lineId ?? ''} + />
); }; diff --git a/src/pages/story/hooks/useBatchSelection.ts b/src/pages/story/hooks/useBatchSelection.ts new file mode 100644 index 0000000..3facff8 --- /dev/null +++ b/src/pages/story/hooks/useBatchSelection.ts @@ -0,0 +1,178 @@ +/** + * useBatchSelection - 批量选择状态管理 Hook + * + * 功能描述: + * 提供批量选择的状态管理和操作方法,用于时间线节点的批量操作功能。 + * + * 功能特性: + * - 管理选中项 ID 集合 + * - 提供选择/取消选择/全选/清空等方法 + * - 支持批量模式切换 + * - 提供选中状态判断方法 + * + * @author Timeline Team + * @date 2024 + */ + +import { useState, useCallback, useMemo } from 'react'; + +/** + * 批量选择状态接口 + * @property selectedIds - 已选中的 ID 集合 + * @property isBatchMode - 是否处于批量选择模式 + * @property select - 选中某项 + * @property deselect - 取消选中某项 + * @property toggle - 切换选中状态 + * @property selectAll - 全选 + * @property clearSelection - 清空选择 + * @property isSelected - 判断是否选中 + * @property selectedCount - 已选中数量 + * @property enterBatchMode - 进入批量模式 + * @property exitBatchMode - 退出批量模式 + */ +interface BatchSelectionState { + selectedIds: Set; + isBatchMode: boolean; + select: (id: T) => void; + deselect: (id: T) => void; + toggle: (id: T) => void; + selectAll: (ids: T[]) => void; + clearSelection: () => void; + isSelected: (id: T) => boolean; + selectedCount: number; + selectedIdsArray: T[]; + enterBatchMode: () => void; + exitBatchMode: () => void; +} + +/** + * useBatchSelection Hook + * 管理批量选择状态 + * + * @template T - ID 类型,默认为 string + * @returns 批量选择状态和操作方法 + * + * @example + * const batchSelection = useBatchSelection(); + * + * // 进入批量模式 + * batchSelection.enterBatchMode(); + * + * // 选择项目 + * batchSelection.toggle('item-1'); + * + * // 获取选中数量 + * console.log(batchSelection.selectedCount); + * + * // 退出批量模式 + * batchSelection.exitBatchMode(); + */ +function useBatchSelection(): BatchSelectionState { + // 已选中的 ID 集合 + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // 是否处于批量选择模式 + const [isBatchMode, setIsBatchMode] = useState(false); + + /** + * 选中某项 + */ + const select = useCallback((id: T) => { + setSelectedIds((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + }, []); + + /** + * 取消选中某项 + */ + const deselect = useCallback((id: T) => { + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }, []); + + /** + * 切换选中状态 + */ + const toggle = useCallback((id: T) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + /** + * 全选 + */ + const selectAll = useCallback((ids: T[]) => { + setSelectedIds(new Set(ids)); + }, []); + + /** + * 清空选择 + */ + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + }, []); + + /** + * 判断是否选中 + */ + const isSelected = useCallback( + (id: T) => selectedIds.has(id), + [selectedIds] + ); + + /** + * 已选中数量 + */ + const selectedCount = useMemo(() => selectedIds.size, [selectedIds]); + + /** + * 已选中的 ID 数组 + */ + const selectedIdsArray = useMemo(() => Array.from(selectedIds), [selectedIds]); + + /** + * 进入批量模式 + */ + const enterBatchMode = useCallback(() => { + setIsBatchMode(true); + }, []); + + /** + * 退出批量模式 + * 同时清空选择 + */ + const exitBatchMode = useCallback(() => { + setIsBatchMode(false); + clearSelection(); + }, [clearSelection]); + + return { + selectedIds, + isBatchMode, + select, + deselect, + toggle, + selectAll, + clearSelection, + isSelected, + selectedCount, + selectedIdsArray, + enterBatchMode, + exitBatchMode, + }; +} + +export default useBatchSelection; diff --git a/src/pages/story/hooks/useHistory.ts b/src/pages/story/hooks/useHistory.ts new file mode 100644 index 0000000..1f8e257 --- /dev/null +++ b/src/pages/story/hooks/useHistory.ts @@ -0,0 +1,322 @@ +/** + * useHistory - 撤销/重做状态管理 Hook + * + * 功能描述: + * 提供撤销和重做功能的状态管理,用于时间线编辑操作的历史记录管理。 + * + * 功能特性: + * - 维护操作历史栈(past)和重做栈(future) + * - 支持 undo/redo 操作 + * - 支持操作数量限制,防止内存溢出 + * - 支持键盘快捷键 (Ctrl+Z / Ctrl+Y) + * - 支持操作描述,便于 UI 展示 + * + * 设计思路: + * 使用两个栈分别存储历史操作和可重做操作: + * - past 栈:存储已执行的操作,用于撤销 + * - future 栈:存储已撤销的操作,用于重做 + * + * @author Timeline Team + * @date 2024 + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; + +/** + * 历史记录项接口 + * @template T - 状态类型 + * @property state - 快照状态 + * @property description - 操作描述 + * @property timestamp - 操作时间戳 + */ +interface HistoryItem { + state: T; + description: string; + timestamp: number; +} + +/** + * 历史记录状态接口 + * @template T - 状态类型 + * @property past - 历史操作栈 + * @property present - 当前状态 + * @property future - 重做操作栈 + */ +interface HistoryState { + past: HistoryItem[]; + present: T; + future: HistoryItem[]; +} + +/** + * 历史记录操作接口 + * @template T - 状态类型 + */ +interface HistoryActions { + /** 撤销操作 */ + undo: () => void; + /** 重做操作 */ + redo: () => void; + /** 推送新状态 */ + push: (state: T, description?: string) => void; + /** 重置历史记录 */ + reset: (state: T) => void; + /** 清空历史记录 */ + clear: () => void; + /** 是否可撤销 */ + canUndo: boolean; + /** 是否可重做 */ + canRedo: boolean; + /** 历史记录数量 */ + historyLength: number; + /** 重做记录数量 */ + futureLength: number; + /** 当前状态 */ + present: T; + /** 获取历史记录列表(用于 UI 展示) */ + getHistoryList: () => HistoryItem[]; +} + +/** + * useHistory Hook 配置选项 + */ +interface UseHistoryOptions { + /** 最大历史记录数量,默认 50 */ + maxHistory?: number; + /** 是否启用键盘快捷键,默认 true */ + enableHotkeys?: boolean; +} + +/** + * useHistory Hook + * 管理撤销/重做状态 + * + * @template T - 状态类型 + * @param initialState - 初始状态 + * @param options - 配置选项 + * @returns 历史记录状态和操作方法 + * + * @example + * const history = useHistory<{ items: Item[] }>( + * { items: [] }, + * { maxHistory: 30, enableHotkeys: true } + * ); + * + * // 推送新状态 + * history.push({ items: newItems }, '添加新节点'); + * + * // 撤销 + * if (history.canUndo) { + * history.undo(); + * } + * + * // 重做 + * if (history.canRedo) { + * history.redo(); + * } + */ +function useHistory( + initialState: T, + options: UseHistoryOptions = {} +): HistoryActions { + const { maxHistory = 50, enableHotkeys = true } = options; + + // 历史记录状态 + const [state, setState] = useState>({ + past: [], + present: initialState, + future: [], + }); + + // 引用最新状态,用于快捷键处理 + const stateRef = useRef(state); + useEffect(() => { + stateRef.current = state; + }, [state]); + + /** + * 推送新状态到历史记录 + * + * 实现逻辑: + * 1. 将当前状态压入 past 栈 + * 2. 更新 present 为新状态 + * 3. 清空 future 栈(新操作使重做失效) + * 4. 检查历史记录数量限制 + */ + const push = useCallback( + (newState: T, description: string = '操作') => { + setState((prev) => { + // 创建新的历史记录项 + const newItem: HistoryItem = { + state: prev.present, + description, + timestamp: Date.now(), + }; + + // 更新 past 栈,并限制数量 + const newPast = [...prev.past, newItem].slice(-maxHistory); + + return { + past: newPast, + present: newState, + future: [], // 新操作清空重做栈 + }; + }); + }, + [maxHistory] + ); + + /** + * 撤销操作 + * + * 实现逻辑: + * 1. 从 past 栈弹出最近的历史记录 + * 2. 将当前状态压入 future 栈 + * 3. 更新 present 为弹出的历史状态 + */ + const undo = useCallback(() => { + setState((prev) => { + if (prev.past.length === 0) { + return prev; + } + + // 弹出最近的历史记录 + const newPast = [...prev.past]; + const previous = newPast.pop()!; + + // 将当前状态压入 future 栈 + const newFuture: HistoryItem[] = [ + { + state: prev.present, + description: '当前状态', + timestamp: Date.now(), + }, + ...prev.future, + ]; + + return { + past: newPast, + present: previous.state, + future: newFuture, + }; + }); + }, []); + + /** + * 重做操作 + * + * 实现逻辑: + * 1. 从 future 栈弹出最近的重做记录 + * 2. 将当前状态压入 past 栈 + * 3. 更新 present 为弹出的重做状态 + */ + const redo = useCallback(() => { + setState((prev) => { + if (prev.future.length === 0) { + return prev; + } + + // 弹出最近的重做记录 + const newFuture = [...prev.future]; + const next = newFuture.shift()!; + + // 将当前状态压入 past 栈 + const newPast: HistoryItem[] = [ + ...prev.past, + { + state: prev.present, + description: '当前状态', + timestamp: Date.now(), + }, + ]; + + return { + past: newPast, + present: next.state, + future: newFuture, + }; + }); + }, []); + + /** + * 重置历史记录 + * 清空所有历史,设置新的当前状态 + */ + const reset = useCallback((newState: T) => { + setState({ + past: [], + present: newState, + future: [], + }); + }, []); + + /** + * 清空历史记录 + * 保留当前状态,清空 past 和 future + */ + const clear = useCallback(() => { + setState((prev) => ({ + past: [], + present: prev.present, + future: [], + })); + }, []); + + /** + * 获取历史记录列表 + * 用于 UI 展示(如历史记录面板) + */ + const getHistoryList = useCallback(() => { + return state.past; + }, [state.past]); + + /** + * 键盘快捷键处理 + * Ctrl+Z: 撤销 + * Ctrl+Y / Ctrl+Shift+Z: 重做 + */ + useEffect(() => { + if (!enableHotkeys) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // 检查是否按下 Ctrl 或 Cmd (Mac) + const isModKey = e.ctrlKey || e.metaKey; + + if (!isModKey) return; + + // 撤销: Ctrl+Z + if (e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + if (stateRef.current.past.length > 0) { + undo(); + } + } + // 重做: Ctrl+Y 或 Ctrl+Shift+Z + else if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) { + e.preventDefault(); + if (stateRef.current.future.length > 0) { + redo(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [enableHotkeys, undo, redo]); + + return { + undo, + redo, + push, + reset, + clear, + canUndo: state.past.length > 0, + canRedo: state.future.length > 0, + historyLength: state.past.length, + futureLength: state.future.length, + present: state.present, + getHistoryList, + }; +} + +export default useHistory; diff --git a/src/pages/story/index.tsx b/src/pages/story/index.tsx index a2d180c..632b36f 100644 --- a/src/pages/story/index.tsx +++ b/src/pages/story/index.tsx @@ -1,26 +1,47 @@ -import { DownOutlined, PlusOutlined } from '@ant-design/icons'; +import Highlight from '@/components/Highlight'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { judgePermission } from '@/pages/story/utils/utils'; +import { DownOutlined, MoreOutlined, PlusOutlined } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-components'; import { history, useRequest, useSearchParams } from '@umijs/max'; -import { Avatar, Button, Card, Dropdown, Input, List, message, Modal } from 'antd'; +import { Avatar, Button, Card, Dropdown, Input, List, MenuProps, message, Modal } from 'antd'; import type { FC } from 'react'; import React, { useEffect, useState } from 'react'; -import OperationModal from './components/OperationModal'; -import type { StoryType, StoryItem } from './data.d'; -import { addStory, deleteStory, queryTimelineList, updateStory, searchStoryItems } from './service'; -import useStyles from './style.style'; import AuthorizeStoryModal from './components/AuthorizeStoryModal'; -import {judgePermission} from "@/pages/story/utils/utils"; -import Highlight from '@/components/Highlight'; - +import OperationModal from './components/OperationModal'; +import type { StoryItem, StoryType } from './data.d'; +import { addStory, deleteStory, queryTimelineList, searchStoryItems, updateStory } from './service'; +import useStyles from './style.style'; const { Search } = Input; const ListContent = ({ data: { storyTime, updateTime, updateName, ownerName, itemCount }, + isMobile, }: { data: StoryType; + isMobile: boolean; }) => { const { styles } = useStyles(); + + if (isMobile) { + return ( +
+
+ 更新时间: +

{updateTime}

+
+
+ 节点数: +

{itemCount}

+
+
+ ); + } + return (
@@ -48,6 +69,7 @@ const ListContent = ({ }; export const BasicList: FC = () => { const { styles } = useStyles(); + const isMobile = useIsMobile(); const [searchParams] = useSearchParams(); const [done, setDone] = useState(false); const [open, setVisible] = useState(false); @@ -70,7 +92,7 @@ export const BasicList: FC = () => { page: searchPagination.current, pageSize: searchPagination.pageSize, }; - + if (!finalParams.keyword) return Promise.resolve({ list: [], total: 0 }); return searchStoryItems({ keyword: finalParams.keyword, @@ -89,7 +111,7 @@ export const BasicList: FC = () => { })); } }, - } + }, ); // 监听 URL 参数变化,自动触发搜索 @@ -97,7 +119,7 @@ export const BasicList: FC = () => { const keyword = searchParams.get('keyword'); const page = parseInt(searchParams.get('page') || '1', 10); if (keyword) { - setSearchPagination(prev => ({ ...prev, keyword, current: page })); + setSearchPagination((prev) => ({ ...prev, keyword, current: page })); setIsSearching(true); searchRun({ keyword, page, pageSize: 10 }); } else { @@ -159,18 +181,27 @@ export const BasicList: FC = () => { cancelText: '取消', onOk: () => deleteItem(currentItem.instanceId ?? ''), }); + } else if (key === 'authorize') { + setCurrent(currentItem); + setAuthorizeModelOpen(true); + } else if (key === 'share') { + const shareLink = `${window.location.origin}/share/${currentItem.shareId}`; + navigator.clipboard.writeText(shareLink); + message.success('分享链接已复制到剪贴板'); } }; const extraContent = ( -
+
); const MoreBtn: React.FC<{ item: StoryType; - }> = ({ item }) => ( - editAndDelete(key, item), - items: [ - { - key: 'edit', - label: '编辑', - }, - { - key: 'delete', - label: '删除', - }, - ], - }} - > - - 更多 - - - ); + }> = ({ item }) => { + const items: MenuProps['items'] = []; + + if (judgePermission(item?.permissionType, 'edit')) { + items.push({ + key: 'edit', + label: '编辑', + }); + } + + if (judgePermission(item?.permissionType, 'auth')) { + items.push({ + key: 'authorize', + label: '授权', + }); + } + + if (judgePermission(item?.permissionType, 'delete')) { + items.push({ + key: 'delete', + label: '删除', + }); + } + + items.push({ + key: 'share', + label: '分享', + }); + + if (items.length === 0) return null; + + return ( + editAndDelete(key, item), + items: items, + }} + > + {isMobile ? ( +