feat: 实现时间线拖拽排序功能及PWA支持
Some checks failed
test/timeline-frontend/pipeline/head Something is wrong with the build of this commit
Some checks failed
test/timeline-frontend/pipeline/head Something is wrong with the build of this commit
新增时间线节点的拖拽排序功能,使用dnd-kit库实现可排序网格布局。添加PWA支持,包括Service Worker注册和manifest配置。优化移动端适配,改进批量操作工具栏和撤销/重做功能。 重构用户登录和注册页面,修复登录跳转逻辑。调整画廊视图在不同设备上的显示效果。新增协作成员管理功能,支持批量修改权限。 修复请求错误处理中的跳转逻辑问题,避免重复跳转登录页。优化样式表,增强时间线卡片和图片展示的响应式布局。 新增多个API接口支持批量操作,包括排序、删除和时间修改。引入useBatchSelection和useHistory自定义Hook管理状态。添加UndoRedoToolbar组件提供撤销/重做功能。 实现Service Worker离线缓存策略,支持静态资源和API请求的缓存。新增PWA工具函数处理安装提示和更新检测。优化移动端交互,调整组件布局和操作按钮。
This commit is contained in:
107
public/manifest.json
Normal file
107
public/manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
247
public/sw.js
Normal file
247
public/sw.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<GlobalHeaderRightProps> = ({ 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
319
src/hooks/useImageCompression.ts
Normal file
319
src/hooks/useImageCompression.ts
Normal file
@@ -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<CompressionResult>;
|
||||
/** 批量压缩图片 */
|
||||
compressMultiple: (files: File[], options?: CompressionOptions) => Promise<CompressionResult[]>;
|
||||
/** 是否正在压缩 */
|
||||
compressing: boolean;
|
||||
/** 压缩进度 (0-100) */
|
||||
progress: number;
|
||||
/** 压缩错误信息 */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认压缩配置
|
||||
*/
|
||||
const DEFAULT_OPTIONS: Required<CompressionOptions> = {
|
||||
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<string | null>(null);
|
||||
|
||||
/**
|
||||
* 压缩单个图片
|
||||
*
|
||||
* 实现步骤:
|
||||
* 1. 读取图片文件
|
||||
* 2. 创建 Image 对象获取尺寸
|
||||
* 3. 计算压缩后尺寸
|
||||
* 4. 使用 Canvas 绘制并导出
|
||||
* 5. 返回压缩结果
|
||||
*/
|
||||
const compress = useCallback(
|
||||
async (file: File, options?: CompressionOptions): Promise<CompressionResult> => {
|
||||
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<CompressionResult[]> => {
|
||||
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;
|
||||
@@ -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<tabKeyType>('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 (
|
||||
<GridContent>
|
||||
<Row gutter={24}>
|
||||
<Col lg={7} md={24}>
|
||||
<Col lg={7} md={24} xs={24} style={{ marginBottom: isMobile ? 24 : 0 }}>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{
|
||||
@@ -227,7 +232,7 @@ const Center: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col lg={17} md={24}>
|
||||
<Col lg={17} md={24} xs={24}>
|
||||
<Card
|
||||
className={styles.tabsCard}
|
||||
bordered={false}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
SmallDashOutlined,
|
||||
UploadOutlined
|
||||
UploadOutlined,
|
||||
MoreOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { RadioChangeEvent } from 'antd';
|
||||
import { Button, Radio, Space, Upload } from 'antd';
|
||||
import { Button, Radio, Space, Upload, Dropdown, Menu } from 'antd';
|
||||
import { FC } from 'react';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
interface GalleryToolbarProps {
|
||||
viewMode: 'small' | 'large' | 'list' | 'table';
|
||||
@@ -36,6 +38,8 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
||||
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<GalleryToolbarProps> = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
const mobileMenu = (
|
||||
<Menu>
|
||||
<Menu.Item key="batch" onClick={onBatchModeToggle}>
|
||||
批量操作
|
||||
</Menu.Item>
|
||||
<Menu.SubMenu key="view" title="视图切换">
|
||||
<Menu.Item key="small" onClick={() => onViewModeChange({ target: { value: 'small' } } as any)}>小图</Menu.Item>
|
||||
<Menu.Item key="large" onClick={() => onViewModeChange({ target: { value: 'large' } } as any)}>大图</Menu.Item>
|
||||
<Menu.Item key="list" onClick={() => onViewModeChange({ target: { value: 'list' } } as any)}>列表</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Space wrap={isMobile}>
|
||||
{batchMode ? (
|
||||
<>
|
||||
<Button onClick={onCancelBatch}>取消</Button>
|
||||
<Button onClick={onCancelBatch} size={isMobile ? "small" : "middle"}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={onBatchDownload}
|
||||
disabled={selectedCount === 0}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
>
|
||||
下载({selectedCount})
|
||||
{isMobile ? `下载(${selectedCount})` : `下载(${selectedCount})`}
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={onBatchDelete}
|
||||
disabled={selectedCount === 0}
|
||||
size={isMobile ? "small" : "middle"}
|
||||
>
|
||||
删除({selectedCount})
|
||||
{isMobile ? `删除(${selectedCount})` : `删除(${selectedCount})`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -80,13 +99,19 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
loading={uploading}
|
||||
type="primary"
|
||||
>
|
||||
上传
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{isMobile ? (
|
||||
<Dropdown overlay={mobileMenu} trigger={['click']}>
|
||||
<Button icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onBatchModeToggle}>批量操作</Button>
|
||||
</>
|
||||
)}
|
||||
<Radio.Group
|
||||
value={viewMode}
|
||||
onChange={onViewModeChange}
|
||||
@@ -104,6 +129,10 @@ const GalleryToolbar: FC<GalleryToolbarProps> = ({
|
||||
</Radio.Button>
|
||||
<Radio.Button value="table">表格</Radio.Button>
|
||||
</Radio.Group>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<GridViewProps> = ({
|
||||
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<GridViewProps> = ({
|
||||
default:
|
||||
return { width: 150, height: 150 };
|
||||
}
|
||||
}, [viewMode]);
|
||||
}, [viewMode, isMobile, windowWidth]);
|
||||
|
||||
const imageSize = getImageSize();
|
||||
|
||||
@@ -103,9 +126,19 @@ const GridView: FC<GridViewProps> = ({
|
||||
<div
|
||||
className={viewMode === 'small' ? 'small-grid-view' : 'large-grid-view'}
|
||||
onScroll={onScroll}
|
||||
style={isMobile ? {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: viewMode === 'large' ? 'repeat(2, 1fr)' : 'repeat(3, 1fr)',
|
||||
gap: '8px',
|
||||
paddingBottom: '20px'
|
||||
} : {}}
|
||||
>
|
||||
{imageList.map((item: ImageItem, index: number) => (
|
||||
<div key={item.instanceId} className="image-card">
|
||||
<div
|
||||
key={item.instanceId}
|
||||
className="image-card"
|
||||
style={isMobile ? { width: '100%', margin: 0 } : {}}
|
||||
>
|
||||
{batchMode && (
|
||||
<Checkbox
|
||||
className="image-checkbox"
|
||||
@@ -116,7 +149,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
<div
|
||||
className="image-wrapper"
|
||||
style={{
|
||||
width: imageSize.width,
|
||||
width: isMobile ? '100%' : imageSize.width,
|
||||
height: imageSize.height,
|
||||
backgroundImage: `url(${getImageUrl(item, false)})`,
|
||||
backgroundSize: 'cover',
|
||||
@@ -130,7 +163,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
onClick={() => !batchMode && onPreview(index)}
|
||||
>
|
||||
{(item.duration || item.thumbnailInstanceId) && (
|
||||
<PlayCircleOutlined style={{ fontSize: '32px', color: 'rgba(255,255,255,0.8)' }} />
|
||||
<PlayCircleOutlined style={{ fontSize: isMobile ? '24px' : '32px', color: 'rgba(255,255,255,0.8)' }} />
|
||||
)}
|
||||
{item.duration && (
|
||||
<span
|
||||
@@ -154,7 +187,7 @@ const GridView: FC<GridViewProps> = ({
|
||||
{item.imageName}
|
||||
</div>
|
||||
<Dropdown overlay={getImageMenu(item)} trigger={['click']}>
|
||||
<Button type="text" icon={<MoreOutlined />} />
|
||||
<Button type="text" icon={<MoreOutlined />} size={isMobile ? "small" : "middle"} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// 当视图模式改变时重置分页
|
||||
|
||||
234
src/pages/story/components/BatchOperationToolbar.tsx
Normal file
234
src/pages/story/components/BatchOperationToolbar.tsx
Normal file
@@ -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<BatchOperationToolbarProps> = 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: <ClockCircleOutlined />,
|
||||
onClick: onBatchChangeTime,
|
||||
disabled: selectedCount === 0 || !onBatchChangeTime,
|
||||
},
|
||||
{
|
||||
key: 'move',
|
||||
label: '移动到...',
|
||||
icon: <ExportOutlined />,
|
||||
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 (
|
||||
<div style={isMobile ? mobileStyle : desktopStyle}>
|
||||
{/* 左侧:选择状态 */}
|
||||
<Space>
|
||||
<Badge
|
||||
count={selectedCount}
|
||||
showZero
|
||||
style={{ backgroundColor: '#1890ff' }}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>
|
||||
已选择 {selectedCount} / {totalCount} 项
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={onSelectAll}
|
||||
disabled={selectedCount === totalCount}
|
||||
>
|
||||
{selectedCount === totalCount ? '已全选' : '全选'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<Space>
|
||||
{isMobile ? (
|
||||
// 移动端:精简按钮
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleBatchDelete}
|
||||
disabled={selectedCount === 0}
|
||||
loading={loading}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Dropdown menu={{ items: moreMenuItems }} placement="topRight">
|
||||
<Button size="small">更多</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// 桌面端:完整按钮
|
||||
<>
|
||||
<Popconfirm
|
||||
title="确认批量删除"
|
||||
description={`确定要删除选中的 ${selectedCount} 个节点吗?此操作不可恢复。`}
|
||||
onConfirm={handleBatchDelete}
|
||||
okText="删除"
|
||||
okType="danger"
|
||||
cancelText="取消"
|
||||
open={deleteConfirmVisible}
|
||||
onOpenChange={setDeleteConfirmVisible}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedCount === 0}
|
||||
loading={loading}
|
||||
>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{onBatchChangeTime && (
|
||||
<Button
|
||||
icon={<ClockCircleOutlined />}
|
||||
disabled={selectedCount === 0}
|
||||
onClick={onBatchChangeTime}
|
||||
>
|
||||
修改时间
|
||||
</Button>
|
||||
)}
|
||||
{onBatchMove && (
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
disabled={selectedCount === 0}
|
||||
onClick={onBatchMove}
|
||||
>
|
||||
移动到...
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onCancel}>
|
||||
取消选择
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
BatchOperationToolbar.displayName = 'BatchOperationToolbar';
|
||||
|
||||
export default BatchOperationToolbar;
|
||||
199
src/pages/story/components/CollaboratorModal.tsx
Normal file
199
src/pages/story/components/CollaboratorModal.tsx
Normal file
@@ -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<number, string> = {
|
||||
1: '创建者',
|
||||
2: '管理员',
|
||||
3: '编辑者',
|
||||
4: '仅查看',
|
||||
};
|
||||
|
||||
const InviteStatusMap: Record<number, { text: string; color: string }> = {
|
||||
0: { text: '待处理', color: 'orange' },
|
||||
1: { text: '已接受', color: 'green' },
|
||||
2: { text: '已拒绝', color: 'red' },
|
||||
};
|
||||
|
||||
const CollaboratorModal: React.FC<CollaboratorModalProps> = ({ 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 = (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="输入用户ID邀请"
|
||||
value={inviteUserId}
|
||||
onChange={(e) => setInviteUserId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
value={invitePermissionType}
|
||||
onChange={setInvitePermissionType}
|
||||
options={[
|
||||
{ label: '管理员', value: 2 },
|
||||
{ label: '编辑者', value: 3 },
|
||||
{ label: '仅查看', value: 4 },
|
||||
]}
|
||||
style={{ width: isMobile ? '100%' : 100, flex: isMobile ? 1 : undefined }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleInvite}
|
||||
style={{ flex: isMobile ? '0 0 auto' : undefined }}
|
||||
>
|
||||
邀请
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={permissions}
|
||||
renderItem={(item: any) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
item.permissionType !== 1 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: 4,
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
defaultValue={item.permissionType}
|
||||
onChange={(val) => handleUpdatePermission(item.permissionId, val)}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
options={[
|
||||
{ label: '管理员', value: 2 },
|
||||
{ label: '编辑者', value: 3 },
|
||||
{ label: '仅查看', value: 4 },
|
||||
]}
|
||||
disabled={item.inviteStatus === 2} // Rejected
|
||||
/>
|
||||
<Popconfirm title="确定移除?" onConfirm={() => handleRemove(item.permissionId)}>
|
||||
<Button type="link" danger size="small">
|
||||
移除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span>用户ID: {item.userId}</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space size={4} wrap>
|
||||
<Tag>{PermissionTypeMap[item.permissionType]}</Tag>
|
||||
{item.inviteStatus !== undefined && InviteStatusMap[item.inviteStatus] && (
|
||||
<Tag color={InviteStatusMap[item.inviteStatus].color}>
|
||||
{InviteStatusMap[item.inviteStatus].text}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
title="协作成员管理"
|
||||
open={visible}
|
||||
onClose={onCancel}
|
||||
placement="bottom"
|
||||
height="80vh"
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="协作成员管理" open={visible} onCancel={onCancel} footer={null} width={600}>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollaboratorModal;
|
||||
288
src/pages/story/components/SortableTimelineGrid.tsx
Normal file
288
src/pages/story/components/SortableTimelineGrid.tsx
Normal file
@@ -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<SortableTimelineGridProps> = memo(({
|
||||
items,
|
||||
dateKey,
|
||||
sortValue,
|
||||
handleOption,
|
||||
onOpenDetail,
|
||||
refresh,
|
||||
disableEdit = false,
|
||||
onOrderChange,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 当前拖拽中的节点ID
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// 本地排序状态(用于即时UI更新)
|
||||
const [localItems, setLocalItems] = useState<StoryItem[]>(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 (
|
||||
<div className="timeline-grid-wrapper" style={{ position: 'relative' }}>
|
||||
{/* 保存中遮罩 */}
|
||||
{saving && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Spin tip="保存排序中..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拖拽上下文容器 */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
measuring={{
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 可排序上下文 - 使用矩形排序策略 */}
|
||||
<SortableContext items={itemIds} strategy={rectSortingStrategy}>
|
||||
{localItems.map((item) => (
|
||||
<SortableTimelineGridItem
|
||||
key={item.instanceId}
|
||||
item={item}
|
||||
disabled={disableEdit || isMobile}
|
||||
handleOption={handleOption}
|
||||
onOpenDetail={onOpenDetail}
|
||||
refresh={refresh}
|
||||
disableEdit={disableEdit}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{/* 拖拽覆盖层 - 显示拖拽中的节点预览 */}
|
||||
<DragOverlay adjustScale style={{ transformOrigin: '0 0' }}>
|
||||
{activeItem ? (
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.9,
|
||||
transform: 'scale(1.02)',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<TimelineGridItem
|
||||
item={activeItem}
|
||||
handleOption={handleOption}
|
||||
onOpenDetail={onOpenDetail}
|
||||
refresh={refresh}
|
||||
disableEdit={true}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SortableTimelineGrid.displayName = 'SortableTimelineGrid';
|
||||
|
||||
export default SortableTimelineGrid;
|
||||
159
src/pages/story/components/SortableTimelineGridItem.tsx
Normal file
159
src/pages/story/components/SortableTimelineGridItem.tsx
Normal file
@@ -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<SortableTimelineGridItemProps> = 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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="sortable-timeline-item-wrapper"
|
||||
>
|
||||
{/* 拖拽手柄 - 仅在非禁用状态显示 */}
|
||||
{!disabled && !disableEdit && (
|
||||
<div
|
||||
className="drag-handle"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 20,
|
||||
cursor: 'grab',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(0, 0, 0, 0.6)',
|
||||
borderRadius: 4,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DragOutlined style={{ color: '#fff', fontSize: 14 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原有组件 */}
|
||||
<TimelineGridItem
|
||||
item={item}
|
||||
handleOption={handleOption}
|
||||
onOpenDetail={onOpenDetail}
|
||||
refresh={refresh}
|
||||
disableEdit={disableEdit}
|
||||
/>
|
||||
|
||||
{/* 拖拽时的视觉反馈 */}
|
||||
{isDragging && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
border: '2px dashed #1890ff',
|
||||
borderRadius: 8,
|
||||
pointerEvents: 'none',
|
||||
background: 'rgba(24, 144, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SortableTimelineGridItem.displayName = 'SortableTimelineGridItem';
|
||||
|
||||
export default SortableTimelineGridItem;
|
||||
145
src/pages/story/components/UndoRedoToolbar.tsx
Normal file
145
src/pages/story/components/UndoRedoToolbar.tsx
Normal file
@@ -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<UndoRedoToolbarProps> = 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: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', minWidth: 150 }}>
|
||||
<span>{item.description}</span>
|
||||
<span style={{ color: '#999', fontSize: 12 }}>
|
||||
{new Date(item.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
|
||||
// 紧凑模式(移动端)
|
||||
if (compact) {
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Tooltip title={`撤销 (Ctrl+Z)${historyLength > 0 ? ` - ${historyLength}步可用` : ''}`}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={`重做 (Ctrl+Y)${futureLength > 0 ? ` - ${futureLength}步可用` : ''}`}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// 完整模式(桌面端)
|
||||
return (
|
||||
<Space size={8}>
|
||||
<Tooltip title={`撤销 (Ctrl+Z)`}>
|
||||
<Badge count={historyLength} size="small" offset={[-5, 5]}>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={`重做 (Ctrl+Y)`}>
|
||||
<Badge count={futureLength} size="small" offset={[-5, 5]}>
|
||||
<Button
|
||||
icon={<RedoOutlined />}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
重做
|
||||
</Button>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
{/* 历史记录下拉菜单 */}
|
||||
{getHistoryList && historyMenuItems.length > 0 && (
|
||||
<Dropdown menu={{ items: historyMenuItems }} placement="bottomRight">
|
||||
<Button icon={<HistoryOutlined />}>
|
||||
历史
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
});
|
||||
|
||||
UndoRedoToolbar.displayName = 'UndoRedoToolbar';
|
||||
|
||||
export default UndoRedoToolbar;
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<StoryItem>();
|
||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 30 });
|
||||
const [isRefreshing, setIsRefreshing] = useState(false); // 是否正在刷新最新数据
|
||||
@@ -264,13 +252,45 @@ const Index = () => {
|
||||
|
||||
const groupedItems = groupItemsByDate(items);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
onBack={() => history.push('/story')}
|
||||
title={
|
||||
queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
|
||||
const getExtraContent = () => {
|
||||
if (isMobile) {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'refresh',
|
||||
label: '刷新',
|
||||
icon: <SyncOutlined />,
|
||||
onClick: () => {
|
||||
setItems([]);
|
||||
setPagination({ current: 1, pageSize: 30 });
|
||||
setLoadDirection('refresh');
|
||||
run({ current: 1 });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (judgePermission(detail?.permissionType ?? null, 'auth')) {
|
||||
menuItems.unshift({
|
||||
key: 'collaborators',
|
||||
label: '协作成员',
|
||||
icon: <TeamOutlined />,
|
||||
onClick: () => setOpenCollaboratorModal(true),
|
||||
});
|
||||
}
|
||||
extra={
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Button icon={<MoreOutlined />} type="text" />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space>
|
||||
{judgePermission(detail?.permissionType ?? null, 'auth') && (
|
||||
<Button icon={<TeamOutlined />} onClick={() => setOpenCollaboratorModal(true)}>
|
||||
协作成员
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => {
|
||||
@@ -283,7 +303,17 @@ const Index = () => {
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
onBack={() => history.push('/story')}
|
||||
title={
|
||||
queryDetailLoading ? '加载中' : `${detail?.title} ${`共${detail?.itemCount ?? 0}个时刻`}`
|
||||
}
|
||||
extra={getExtraContent()}
|
||||
>
|
||||
<div
|
||||
className="timeline"
|
||||
@@ -305,22 +335,14 @@ const Index = () => {
|
||||
</div>
|
||||
)}
|
||||
{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 }) => (
|
||||
<div key={dateKey}>
|
||||
<h2 className="timeline-section-header">{dateKey}</h2>
|
||||
<div className="timeline-grid-wrapper">
|
||||
{dateItems.map((item, index) => {
|
||||
// 调试:确保每个item都有有效的数据
|
||||
if (!item || (!item.id && !item.instanceId)) {
|
||||
console.warn('发现无效的item:', item, 'at index:', index);
|
||||
return null; // 不渲染无效的item
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineGridItem
|
||||
key={item.id ?? item.instanceId}
|
||||
item={item}
|
||||
<SortableTimelineGrid
|
||||
items={dateItems}
|
||||
dateKey={dateKey}
|
||||
sortValue={sortValue}
|
||||
handleOption={(
|
||||
item: StoryItem,
|
||||
option: 'add' | 'edit' | 'addSubItem' | 'editSubItem',
|
||||
@@ -342,10 +364,19 @@ const Index = () => {
|
||||
run({ current: 1 });
|
||||
queryDetail();
|
||||
}}
|
||||
/>
|
||||
onOrderChange={(changedDateKey, newItems) => {
|
||||
setItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const startIdx = updated.findIndex(
|
||||
(item) => item.storyItemTime === newItems[0]?.storyItemTime
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
if (startIdx !== -1) {
|
||||
updated.splice(startIdx, newItems.length, ...newItems);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="load-indicator">加载中...</div>}
|
||||
@@ -498,6 +529,11 @@ const Index = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CollaboratorModal
|
||||
visible={openCollaboratorModal}
|
||||
onCancel={() => setOpenCollaboratorModal(false)}
|
||||
storyId={lineId ?? ''}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
178
src/pages/story/hooks/useBatchSelection.ts
Normal file
178
src/pages/story/hooks/useBatchSelection.ts
Normal file
@@ -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<T extends string = string> {
|
||||
selectedIds: Set<T>;
|
||||
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<string>();
|
||||
*
|
||||
* // 进入批量模式
|
||||
* batchSelection.enterBatchMode();
|
||||
*
|
||||
* // 选择项目
|
||||
* batchSelection.toggle('item-1');
|
||||
*
|
||||
* // 获取选中数量
|
||||
* console.log(batchSelection.selectedCount);
|
||||
*
|
||||
* // 退出批量模式
|
||||
* batchSelection.exitBatchMode();
|
||||
*/
|
||||
function useBatchSelection<T extends string = string>(): BatchSelectionState<T> {
|
||||
// 已选中的 ID 集合
|
||||
const [selectedIds, setSelectedIds] = useState<Set<T>>(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;
|
||||
322
src/pages/story/hooks/useHistory.ts
Normal file
322
src/pages/story/hooks/useHistory.ts
Normal file
@@ -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<T> {
|
||||
state: T;
|
||||
description: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录状态接口
|
||||
* @template T - 状态类型
|
||||
* @property past - 历史操作栈
|
||||
* @property present - 当前状态
|
||||
* @property future - 重做操作栈
|
||||
*/
|
||||
interface HistoryState<T> {
|
||||
past: HistoryItem<T>[];
|
||||
present: T;
|
||||
future: HistoryItem<T>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史记录操作接口
|
||||
* @template T - 状态类型
|
||||
*/
|
||||
interface HistoryActions<T> {
|
||||
/** 撤销操作 */
|
||||
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<T>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T>(
|
||||
initialState: T,
|
||||
options: UseHistoryOptions = {}
|
||||
): HistoryActions<T> {
|
||||
const { maxHistory = 50, enableHotkeys = true } = options;
|
||||
|
||||
// 历史记录状态
|
||||
const [state, setState] = useState<HistoryState<T>>({
|
||||
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<T> = {
|
||||
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<T>[] = [
|
||||
{
|
||||
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<T>[] = [
|
||||
...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;
|
||||
@@ -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 (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div
|
||||
className={styles.listContentItem}
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 4 }}
|
||||
>
|
||||
<span>更新时间: </span>
|
||||
<p>{updateTime}</p>
|
||||
</div>
|
||||
<div className={styles.listContentItem} style={{ width: '100%', textAlign: 'left' }}>
|
||||
<span>节点数: </span>
|
||||
<p>{itemCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.listContentItem}>
|
||||
@@ -48,6 +69,7 @@ const ListContent = ({
|
||||
};
|
||||
export const BasicList: FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const isMobile = useIsMobile();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [done, setDone] = useState<boolean>(false);
|
||||
const [open, setVisible] = useState<boolean>(false);
|
||||
@@ -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 = (
|
||||
<div>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, width: isMobile ? '100%' : 'auto' }}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
float: 'left'
|
||||
marginBottom: isMobile ? 0 : 8,
|
||||
float: isMobile ? 'none' : 'left',
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
@@ -182,32 +213,60 @@ export const BasicList: FC = () => {
|
||||
onSearch={(value) => {
|
||||
history.push(`/story?keyword=${value}&page=1`);
|
||||
}}
|
||||
style={{ width: isMobile ? '100%' : 272 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const MoreBtn: React.FC<{
|
||||
item: StoryType;
|
||||
}> = ({ item }) => (
|
||||
}> = ({ 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 (
|
||||
<Dropdown
|
||||
menu={{
|
||||
onClick: ({ key }) => editAndDelete(key, item),
|
||||
items: [
|
||||
{
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
},
|
||||
],
|
||||
items: items,
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Button type="text" icon={<MoreOutlined />} />
|
||||
) : (
|
||||
<a>
|
||||
更多 <DownOutlined />
|
||||
</a>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
const handleDone = () => {
|
||||
setDone(false);
|
||||
setVisible(false);
|
||||
@@ -221,7 +280,7 @@ export const BasicList: FC = () => {
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<PageContainer title={"Timeline"}>
|
||||
<PageContainer title={'Timeline'}>
|
||||
<div className={styles.standardList}>
|
||||
<Card
|
||||
className={styles.listCard}
|
||||
@@ -230,7 +289,7 @@ export const BasicList: FC = () => {
|
||||
marginTop: 24,
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '0 32px 40px 32px',
|
||||
padding: isMobile ? '16px 16px 24px 16px' : '0 32px 40px 32px',
|
||||
}}
|
||||
extra={extraContent}
|
||||
>
|
||||
@@ -273,7 +332,9 @@ export const BasicList: FC = () => {
|
||||
<Highlight text={item.title} keyword={searchPagination.keyword || ''} />
|
||||
</a>
|
||||
}
|
||||
description={<Highlight text={item.content} keyword={searchPagination.keyword || ''} />}
|
||||
description={
|
||||
<Highlight text={item.content} keyword={searchPagination.keyword || ''} />
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
@@ -288,7 +349,10 @@ export const BasicList: FC = () => {
|
||||
dataSource={list}
|
||||
renderItem={(item: StoryType) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
actions={
|
||||
isMobile
|
||||
? [<MoreBtn key="more" item={item} />]
|
||||
: [
|
||||
<a
|
||||
key="edit"
|
||||
disabled={!judgePermission(item?.permissionType, 'edit')}
|
||||
@@ -332,7 +396,8 @@ export const BasicList: FC = () => {
|
||||
>
|
||||
分享
|
||||
</a>,
|
||||
]}
|
||||
]
|
||||
}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={item.logo} shape="square" size="large" />}
|
||||
@@ -347,7 +412,7 @@ export const BasicList: FC = () => {
|
||||
}
|
||||
description={item.description}
|
||||
/>
|
||||
<ListContent data={item} />
|
||||
<ListContent data={item} isMobile={isMobile} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -115,3 +115,108 @@ export async function authorizeStoryPermission(params: {userId: string, storyIns
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStoryPermissions(storyId: string) {
|
||||
return request(`/story/permission/story/${storyId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export async function inviteUser(params: {userId: string, storyInstanceId: string, permissionType: number}) {
|
||||
return request('/story/permission/invite', {
|
||||
method: 'POST',
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptInvite(inviteId: string) {
|
||||
return request(`/story/permission/invite/${inviteId}/accept`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectInvite(inviteId: string) {
|
||||
return request(`/story/permission/invite/${inviteId}/reject`, {
|
||||
method: 'PUT',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePermission(params: {permissionId: string, permissionType: number}) {
|
||||
return request('/story/permission', {
|
||||
method: 'PUT',
|
||||
data: params
|
||||
});
|
||||
}
|
||||
|
||||
export async function removePermission(permissionId: string) {
|
||||
return request(`/story/permission/${permissionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新时间线节点排序
|
||||
*
|
||||
* 功能描述:
|
||||
* 批量更新节点的排序值,用于拖拽排序后保存结果。
|
||||
*
|
||||
* @param orderData - 排序数据数组,包含节点ID和新排序值
|
||||
* @returns API响应
|
||||
*
|
||||
* @example
|
||||
* const orderData = [
|
||||
* { instanceId: 'item-1', sortOrder: 0 },
|
||||
* { instanceId: 'item-2', sortOrder: 1 },
|
||||
* ];
|
||||
* await updateStoryItemOrder(orderData);
|
||||
*/
|
||||
export async function updateStoryItemOrder(
|
||||
orderData: Array<{ instanceId: string; sortOrder: number }>
|
||||
): Promise<CommonResponse<void>> {
|
||||
return request('/story/item/order', {
|
||||
method: 'PUT',
|
||||
data: { items: orderData },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除时间线节点
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据节点ID列表批量删除时间线节点。
|
||||
* 删除操作为软删除,数据可恢复。
|
||||
*
|
||||
* @param instanceIds - 要删除的节点ID数组
|
||||
* @returns API响应
|
||||
*
|
||||
* @example
|
||||
* await batchDeleteStoryItems(['item-1', 'item-2', 'item-3']);
|
||||
*/
|
||||
export async function batchDeleteStoryItems(
|
||||
instanceIds: string[]
|
||||
): Promise<CommonResponse<void>> {
|
||||
return request('/story/item/batch-delete', {
|
||||
method: 'POST',
|
||||
data: { instanceIds },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量修改时间线节点时间
|
||||
*
|
||||
* 功能描述:
|
||||
* 批量修改多个节点的时间信息。
|
||||
*
|
||||
* @param instanceIds - 要修改的节点ID数组
|
||||
* @param storyItemTime - 新的时间值
|
||||
* @returns API响应
|
||||
*/
|
||||
export async function batchUpdateStoryItemTime(
|
||||
instanceIds: string[],
|
||||
storyItemTime: string
|
||||
): Promise<CommonResponse<void>> {
|
||||
return request('/story/item/batch-time', {
|
||||
method: 'PUT',
|
||||
data: { instanceIds, storyItemTime },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Footer } from '@/components';
|
||||
import { getFakeCaptcha } from '@/services/ant-design-pro/login';
|
||||
import { loginUser } from '@/services/user/api';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
import {
|
||||
AlipayCircleOutlined,
|
||||
LockOutlined,
|
||||
@@ -14,14 +15,19 @@ import {
|
||||
ProFormCheckbox,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, Helmet, SelectLang, useIntl, useModel, history, useRequest } from '@umijs/max';
|
||||
import { Alert, message, Tabs } from 'antd';
|
||||
import {
|
||||
FormattedMessage,
|
||||
Helmet,
|
||||
history,
|
||||
SelectLang,
|
||||
useIntl,
|
||||
useModel,
|
||||
useRequest,
|
||||
} from '@umijs/max';
|
||||
import { Alert, message } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import React, { useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import Settings from '../../../../config/defaultSettings';
|
||||
import { loginUser } from '@/services/user/api';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
|
||||
const useStyles = createStyles(({ token }) => {
|
||||
return {
|
||||
@@ -103,9 +109,10 @@ const Login: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
// 使用元组参数签名以匹配 useRequest 重载,避免被分页重载推断
|
||||
const { loading: submitting, run: login } = useRequest<CommonResponse<UserLoginResult>, [UserLoginParams]>(
|
||||
(params: UserLoginParams): Promise<CommonResponse<UserLoginResult>> => loginUser(params),
|
||||
{
|
||||
const { loading: submitting, run: login } = useRequest<
|
||||
CommonResponse<UserLoginResult>,
|
||||
[UserLoginParams]
|
||||
>((params: UserLoginParams): Promise<CommonResponse<UserLoginResult>> => loginUser(params), {
|
||||
manual: true,
|
||||
formatResult: (res) => res,
|
||||
onSuccess: async (response: CommonResponse<UserLoginResult>, params: [UserLoginParams]) => {
|
||||
@@ -119,17 +126,18 @@ const Login: React.FC = () => {
|
||||
});
|
||||
message.success(defaultLoginSuccessMessage);
|
||||
// await fetchUserInfo();
|
||||
localStorage.setItem('timeline_user', JSON.stringify(response.data))
|
||||
localStorage.setItem('timeline_user', JSON.stringify(response.data));
|
||||
const urlParams = new URL(window.location.href).searchParams;
|
||||
window.location.href = urlParams.get('redirect')?.split('?redirect=')[1] || '/';
|
||||
// 修复:直接使用 redirect 参数,如果不存在则跳转到首页
|
||||
const redirect = urlParams.get('redirect');
|
||||
window.location.href = redirect || '/';
|
||||
return;
|
||||
}
|
||||
console.log(response.message);
|
||||
// 如果失败去设置用户错误信息
|
||||
setUserLoginState(response.message as any);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: API.LoginParams) => {
|
||||
await login(values as UserLoginParams);
|
||||
|
||||
@@ -3,11 +3,15 @@ import { createStyles } from 'antd-style';
|
||||
const useStyles = createStyles(() => {
|
||||
return {
|
||||
registerResult: {
|
||||
width: '800px',
|
||||
width: '100%',
|
||||
maxWidth: '800px',
|
||||
minHeight: '400px',
|
||||
margin: 'auto',
|
||||
padding: '80px',
|
||||
padding: '40px 20px',
|
||||
background: 'none',
|
||||
'@media screen and (min-width: 768px)': {
|
||||
padding: '80px',
|
||||
},
|
||||
},
|
||||
anticon: {
|
||||
fontSize: '64px',
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { registerUser } from '@/services/user/api';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
import { history, Link, useRequest } from '@umijs/max';
|
||||
import { Button, Col, Form, Input, message, Popover, Progress, Row, Select, Space } from 'antd';
|
||||
import { Button, Form, Input, message, Popover, Progress, Select, Space } from 'antd';
|
||||
import type { Store } from 'antd/es/form/interface';
|
||||
import type { FC } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { StateType } from './service';
|
||||
import { fakeRegister } from './service';
|
||||
import useStyles from './style.style';
|
||||
import { registerUser } from '@/services/user/api';
|
||||
import { CommonResponse } from '@/types/common';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
const { Option } = Select;
|
||||
@@ -23,6 +21,7 @@ const passwordProgressMap: {
|
||||
};
|
||||
const Register: FC = () => {
|
||||
const { styles } = useStyles();
|
||||
const isMobile = useIsMobile();
|
||||
const [count, setCount]: [number, any] = useState(0);
|
||||
const [open, setVisible]: [boolean, any] = useState(false);
|
||||
const [prefix, setPrefix]: [string, any] = useState('86');
|
||||
@@ -202,7 +201,7 @@ const Register: FC = () => {
|
||||
overlayStyle={{
|
||||
width: 240,
|
||||
}}
|
||||
placement="right"
|
||||
placement={isMobile ? 'top' : 'right'}
|
||||
open={open}
|
||||
>
|
||||
<FormItem
|
||||
|
||||
@@ -123,11 +123,15 @@ export const errorConfig: RequestConfig = {
|
||||
// 无 token 提示并引导登录
|
||||
if (!token) {
|
||||
message.warning('请先登录');
|
||||
// 避免URL参数重复,只在没有redirect参数时才添加
|
||||
const currentUrl = window.location.href;
|
||||
const urlParams = new URL(currentUrl).searchParams;
|
||||
const redirect = urlParams.get('redirect');
|
||||
|
||||
// 如果已经在登录页,不需要再次跳转
|
||||
if (window.location.pathname === '/user/login') {
|
||||
return config;
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
history.push(`/user/login?redirect=${redirect}`);
|
||||
} else {
|
||||
|
||||
255
src/utils/pwa.ts
Normal file
255
src/utils/pwa.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* PWA 注册和更新管理
|
||||
*
|
||||
* 功能描述:
|
||||
* 注册 Service Worker,处理 PWA 安装和更新提示。
|
||||
*
|
||||
* 功能特性:
|
||||
* - Service Worker 注册
|
||||
* - 更新检测和提示
|
||||
* - 安装提示
|
||||
* - 离线状态检测
|
||||
*
|
||||
* @author Timeline Team
|
||||
* @date 2024
|
||||
*/
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
// PWA 状态
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
let swRegistration: ServiceWorkerRegistration | null = null;
|
||||
|
||||
/**
|
||||
* 注册 Service Worker
|
||||
*/
|
||||
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
console.log('Service Worker 不支持');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
});
|
||||
|
||||
swRegistration = registration;
|
||||
console.log('Service Worker 注册成功:', registration.scope);
|
||||
|
||||
// 检查更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// 新版本已安装,提示用户刷新
|
||||
showUpdateNotification();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error('Service Worker 注册失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示更新通知
|
||||
*/
|
||||
function showUpdateNotification(): void {
|
||||
const result = confirm('发现新版本,是否立即更新?');
|
||||
if (result) {
|
||||
// 通知 Service Worker 跳过等待
|
||||
if (swRegistration?.waiting) {
|
||||
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听安装提示事件
|
||||
*/
|
||||
export function setupInstallPrompt(): void {
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
|
||||
// 显示安装按钮或提示
|
||||
showInstallButton();
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('PWA 已安装');
|
||||
deferredPrompt = null;
|
||||
hideInstallButton();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发安装提示
|
||||
*/
|
||||
export async function promptInstall(): Promise<boolean> {
|
||||
if (!deferredPrompt) {
|
||||
console.log('安装提示不可用');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
console.log('用户选择:', outcome);
|
||||
deferredPrompt = null;
|
||||
|
||||
return outcome === 'accepted';
|
||||
} catch (error) {
|
||||
console.error('安装提示失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示安装按钮
|
||||
*/
|
||||
function showInstallButton(): void {
|
||||
const installButton = document.getElementById('pwa-install-button');
|
||||
if (installButton) {
|
||||
installButton.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏安装按钮
|
||||
*/
|
||||
function hideInstallButton(): void {
|
||||
const installButton = document.getElementById('pwa-install-button');
|
||||
if (installButton) {
|
||||
installButton.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已安装 PWA
|
||||
*/
|
||||
export function isPwaInstalled(): boolean {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否支持 PWA
|
||||
*/
|
||||
export function isPwaSupported(): boolean {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求推送权限
|
||||
*/
|
||||
export async function requestPushPermission(): Promise<boolean> {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('浏览器不支持通知');
|
||||
return false;
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === 'granted';
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅推送通知
|
||||
*/
|
||||
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
|
||||
if (!swRegistration) {
|
||||
console.log('Service Worker 未注册');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await swRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
|
||||
console.log('推送订阅成功:', subscription);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('推送订阅失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 转 Uint8Array
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听网络状态
|
||||
*/
|
||||
export function setupNetworkListener(
|
||||
onOnline?: () => void,
|
||||
onOffline?: () => void
|
||||
): void {
|
||||
window.addEventListener('online', () => {
|
||||
console.log('网络已连接');
|
||||
onOnline?.();
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('网络已断开');
|
||||
onOffline?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 PWA
|
||||
*/
|
||||
export async function initPwa(): Promise<void> {
|
||||
// 注册 Service Worker
|
||||
await registerServiceWorker();
|
||||
|
||||
// 设置安装提示
|
||||
setupInstallPrompt();
|
||||
|
||||
// 监听网络状态
|
||||
setupNetworkListener(
|
||||
() => console.log('网络已恢复'),
|
||||
() => console.log('网络已断开')
|
||||
);
|
||||
|
||||
console.log('PWA 初始化完成');
|
||||
}
|
||||
|
||||
export default {
|
||||
registerServiceWorker,
|
||||
setupInstallPrompt,
|
||||
promptInstall,
|
||||
isPwaInstalled,
|
||||
isPwaSupported,
|
||||
requestPushPermission,
|
||||
subscribeToPush,
|
||||
setupNetworkListener,
|
||||
initPwa,
|
||||
};
|
||||
Reference in New Issue
Block a user