Merge changes from topic "revert-1445-revert-1443-FEGNGJECHO-ZDOINMGPHW"
* changes:
Revert^2 "api忘改回来了"
Revert^2 "11"
diff --git a/Merge/back_ljc/app.py b/Merge/back_ljc/app.py
index f672fc3..182a6e5 100644
--- a/Merge/back_ljc/app.py
+++ b/Merge/back_ljc/app.py
@@ -1,6 +1,7 @@
from flask import Flask, jsonify, request, session
from flask_sqlalchemy import SQLAlchemy
from flask_cors import CORS
+from flask_jwt_extended import jwt_required, get_jwt_identity
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:123456@10.126.59.25/redbook'
@@ -230,9 +231,9 @@
# 更新用户信息
@app.route('/api/user/<int:user_id>', methods=['PUT'])
def update_user(user_id):
- current_user_id = session.get('user_id', 1)
- if current_user_id != user_id:
- return jsonify({'error': 'Unauthorized'}), 403
+ # current_user_id = session.get('user_id', 1)
+ # if current_user_id != user_id:
+ # return jsonify({'error': 'Unauthorized'}), 403
user = User.query.get(user_id)
if not user:
@@ -265,9 +266,9 @@
if 'user_id' not in session:
return jsonify({'error': '未登录'}), 401
- # 验证请求的用户ID与登录用户ID是否一致
- if session['user_id'] != user_id:
- return jsonify({'error': '无权访问其他用户的收藏'}), 403
+ # # 验证请求的用户ID与登录用户ID是否一致
+ # if session['user_id'] != user_id:
+ # return jsonify({'error': '无权访问其他用户的收藏'}), 403
try:
# 获取收藏行为及其关联的帖子
@@ -351,9 +352,10 @@
return jsonify(following_list)
# 关注/取消关注用户
-@app.route('/api/follow/<int:followee_id>', methods=['POST', 'DELETE'])
-def follow_user(followee_id):
- follower_id = session.get('user_id', 1)
+@app.route('/api/follow/<int:follower_id>/<int:followee_id>', methods=['POST', 'DELETE'])
+def follow_user(follower_id,followee_id):
+ # follower_id = session.get('user_id', 1)
+ print(follower_id)
if follower_id == followee_id:
return jsonify({'error': 'Cannot follow yourself'}), 400
@@ -449,13 +451,13 @@
try:
# 计算用户的获赞总数(所有帖子的点赞数)
like_count = db.session.query(db.func.sum(Behavior.value)).filter(
- Behavior.post.has(user_id=user_id),
+ Behavior.user_id==user_id,
Behavior.type == 'like'
).scalar() or 0
# 计算用户的收藏总数(所有帖子的收藏数)
favorite_count = db.session.query(db.func.sum(Behavior.value)).filter(
- Behavior.post.has(user_id=user_id),
+ Behavior.user_id==user_id,
Behavior.type == 'favorite'
).scalar() or 0
@@ -468,6 +470,130 @@
app.logger.error(f"获取用户互动数据失败: {str(e)}")
return jsonify({'error': '获取互动数据失败'}), 500
+# 点赞/取消点赞路由 - 修改为包含用户ID
+@app.route('/api/users/<int:user_id>/posts/<int:post_id>/like', methods=['POST', 'DELETE'])
+def handle_like(user_id, post_id):
+ # 检查用户是否登录
+ if 'user_id' not in session:
+ return jsonify({'error': '未登录'}), 401
+
+ # 验证请求用户ID与登录用户ID是否一致
+ if session['user_id'] != user_id:
+ return jsonify({'error': '无权限操作'}), 403
+
+ post = Post.query.get(post_id)
+
+ if not post:
+ return jsonify({'error': '帖子不存在'}), 404
+
+ # 检查行为类型
+ behavior = Behavior.query.filter_by(
+ user_id=user_id,
+ post_id=post_id,
+ type='like'
+ ).first()
+
+ if request.method == 'POST':
+ # 点赞
+ if not behavior:
+ new_behavior = Behavior(
+ user_id=user_id,
+ post_id=post_id,
+ type='like',
+ value=1
+ )
+ db.session.add(new_behavior)
+ db.session.commit()
+ return jsonify({'message': '点赞成功', 'liked': True})
+ return jsonify({'message': '已点赞', 'liked': True})
+
+ elif request.method == 'DELETE':
+ # 取消点赞
+ if behavior:
+ db.session.delete(behavior)
+ db.session.commit()
+ return jsonify({'message': '已取消点赞', 'liked': False})
+ return jsonify({'message': '未点赞', 'liked': False})
+
+# 收藏/取消收藏路由 - 修改为包含用户ID
+@app.route('/api/users/<int:user_id>/posts/<int:post_id>/favorite', methods=['POST', 'DELETE'])
+def handle_favorite(user_id, post_id):
+ # 检查用户是否登录
+ if 'user_id' not in session:
+ return jsonify({'error': '未登录'}), 401
+
+ # 验证请求用户ID与登录用户ID是否一致
+ if session['user_id'] != user_id:
+ return jsonify({'error': '无权限操作'}), 403
+
+ post = Post.query.get(post_id)
+
+ if not post:
+ return jsonify({'error': '帖子不存在'}), 404
+
+ # 检查行为类型
+ behavior = Behavior.query.filter_by(
+ user_id=user_id,
+ post_id=post_id,
+ type='favorite'
+ ).first()
+
+ if request.method == 'POST':
+ # 收藏
+ if not behavior:
+ new_behavior = Behavior(
+ user_id=user_id,
+ post_id=post_id,
+ type='favorite',
+ value=1
+ )
+ db.session.add(new_behavior)
+ db.session.commit()
+ return jsonify({'message': '收藏成功', 'favorited': True})
+ return jsonify({'message': '已收藏', 'favorited': True})
+
+ elif request.method == 'DELETE':
+ # 取消收藏
+ if behavior:
+ db.session.delete(behavior)
+ db.session.commit()
+ return jsonify({'message': '已取消收藏', 'favorited': False})
+ return jsonify({'message': '未收藏', 'favorited': False})
+
+# 获取帖子互动状态(是否点赞/收藏) - 修改为包含用户ID
+@app.route('/api/users/<int:user_id>/posts/<int:post_id>/interaction-status')
+def get_post_interaction_status(user_id, post_id):
+ # 检查用户是否登录
+ if 'user_id' not in session:
+ return jsonify({
+ 'liked': False,
+ 'favorited': False
+ })
+
+ # 验证请求用户ID与登录用户ID是否一致
+ if session['user_id'] != user_id:
+ return jsonify({
+ 'liked': False,
+ 'favorited': False
+ })
+
+ liked = Behavior.query.filter_by(
+ user_id=user_id,
+ post_id=post_id,
+ type='like'
+ ).first() is not None
+
+ favorited = Behavior.query.filter_by(
+ user_id=user_id,
+ post_id=post_id,
+ type='favorite'
+ ).first() is not None
+
+ return jsonify({
+ 'liked': liked,
+ 'favorited': favorited
+ })
+
if __name__ == '__main__':
app.run(debug=True,port='5715',host='0.0.0.0')
\ No newline at end of file
diff --git a/Merge/back_wzy/__pycache__/config.cpython-310.pyc b/Merge/back_wzy/__pycache__/config.cpython-310.pyc
index bd938d3..f508f8f 100644
--- a/Merge/back_wzy/__pycache__/config.cpython-310.pyc
+++ b/Merge/back_wzy/__pycache__/config.cpython-310.pyc
Binary files differ
diff --git a/Merge/back_wzy/config.py b/Merge/back_wzy/config.py
index 6a9bf8c..0200846 100644
--- a/Merge/back_wzy/config.py
+++ b/Merge/back_wzy/config.py
@@ -9,4 +9,6 @@
'SQLURL'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
- SQLURL = os.getenv('SQLURL')
\ No newline at end of file
+ SQLURL = os.getenv('SQLURL')
+ # 文件上传配置
+ MAX_CONTENT_LENGTH = 2 * 1024 * 1024 * 1024 # 2GB,支持视频上传
\ No newline at end of file
diff --git a/Merge/back_wzy/utils/Fpost.py b/Merge/back_wzy/utils/Fpost.py
index 2ffffbc..2ed295f 100644
--- a/Merge/back_wzy/utils/Fpost.py
+++ b/Merge/back_wzy/utils/Fpost.py
@@ -177,19 +177,39 @@
"""
media_urls = []
+ # 支持的文件类型
+ ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
+ ALLOWED_VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi'}
+ ALLOWED_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS
+
for file in files:
if file and file.filename:
# 生成安全的文件名
original_filename = secure_filename(file.filename)
# 生成唯一文件名避免冲突
unique_id = str(uuid.uuid4())
- file_extension = os.path.splitext(original_filename)[1]
+ file_extension = os.path.splitext(original_filename)[1].lower()
+
+ # 验证文件类型
+ if file_extension not in ALLOWED_EXTENSIONS:
+ raise Exception(f"不支持的文件类型: {file_extension}")
+
unique_filename = f"{unique_id}{file_extension}"
- # 读取文件内容
+ # 读取文件内容(对于大文件,分块读取)
file_content = file.read()
file.seek(0) # 重置文件指针
+ # 验证文件大小
+ file_size = len(file_content)
+ max_image_size = 32 * 1024 * 1024 # 32MB
+ max_video_size = 2 * 1024 * 1024 * 1024 # 2GB
+
+ if file_extension in ALLOWED_IMAGE_EXTENSIONS and file_size > max_image_size:
+ raise Exception(f"图片文件过大: {file_size / (1024*1024):.1f}MB,最大支持32MB")
+ elif file_extension in ALLOWED_VIDEO_EXTENSIONS and file_size > max_video_size:
+ raise Exception(f"视频文件过大: {file_size / (1024*1024*1024):.1f}GB,最大支持2GB")
+
# 保存到所有存储节点
success_count = 0
for node_path in self.storage_nodes:
diff --git a/Merge/back_wzy/utils/__pycache__/Fpost.cpython-310.pyc b/Merge/back_wzy/utils/__pycache__/Fpost.cpython-310.pyc
index c4b0fad..75ed484 100644
--- a/Merge/back_wzy/utils/__pycache__/Fpost.cpython-310.pyc
+++ b/Merge/back_wzy/utils/__pycache__/Fpost.cpython-310.pyc
Binary files differ
diff --git a/Merge/front/src/api/api_ljc.js b/Merge/front/src/api/api_ljc.js
index 1adea99..8c16245 100644
--- a/Merge/front/src/api/api_ljc.js
+++ b/Merge/front/src/api/api_ljc.js
@@ -3,6 +3,7 @@
const api = axios.create({
baseURL: 'http://10.126.59.25:5715/api/',
+ // baseURL: 'http://127.0.0.1:5715/api/',
withCredentials: true
});
@@ -15,11 +16,11 @@
export const getFavorites = (userId) => api.get(`/user/${userId}/favorites`);
// 关注相关API
-export const followUser = (followeeId) => {
- return api.post(`/follow/${followeeId}`);
+export const followUser = (followerId,followeeId) => {
+ return api.post(`/follow/${followerId}/${followeeId}`);
};
-export const unfollowUser = (followeeId) => {
- return api.delete(`/follow/${followeeId}`);
+export const unfollowUser = (followerId,followeeId) => {
+ return api.delete(`/follow/${followerId}/${followeeId}`);
};
// 帖子相关API
@@ -32,5 +33,56 @@
export const getUserInteractions = (userId) => api.get(`/user/${userId}/interactions`);
// 获取粉丝
export const getUserFollowers = (userId) => api.get(`/user/${userId}/followers`);
+// ================= 帖子互动API =================
+
+/**
+ * 点赞帖子
+ * @param {number} userId 用户ID
+ * @param {number} postId 帖子ID
+ * @returns 操作结果
+ */
+export const likePost = (userId, postId) => {
+ return api.post(`/users/${userId}/posts/${postId}/like`);
+};
+
+/**
+ * 取消点赞
+ * @param {number} userId 用户ID
+ * @param {number} postId 帖子ID
+ * @returns 操作结果
+ */
+export const unlikePost = (userId, postId) => {
+ return api.delete(`/users/${userId}/posts/${postId}/like`);
+};
+
+/**
+ * 收藏帖子
+ * @param {number} userId 用户ID
+ * @param {number} postId 帖子ID
+ * @returns 操作结果
+ */
+export const favoritePost = (userId, postId) => {
+ return api.post(`/users/${userId}/posts/${postId}/favorite`);
+};
+
+/**
+ * 取消收藏
+ * @param {number} userId 用户ID
+ * @param {number} postId 帖子ID
+ * @returns 操作结果
+ */
+export const unfavoritePost = (userId, postId) => {
+ return api.delete(`/users/${userId}/posts/${postId}/favorite`);
+};
+
+/**
+ * 获取当前用户对帖子的互动状态
+ * @param {number} userId 用户ID
+ * @param {number} postId 帖子ID
+ * @returns {object} { liked: boolean, favorited: boolean }
+ */
+export const getPostInteractionStatus = (userId, postId) => {
+ return api.get(`/users/${userId}/posts/${postId}/interaction-status`);
+};
export default api;
\ No newline at end of file
diff --git a/Merge/front/src/components/HomeFeed.jsx b/Merge/front/src/components/HomeFeed.jsx
index 82e027a..0f3c0fe 100644
--- a/Merge/front/src/components/HomeFeed.jsx
+++ b/Merge/front/src/components/HomeFeed.jsx
@@ -8,6 +8,7 @@
import { getUserInfo } from '../utils/auth'
import { deepRecommend } from '../api/recommend_rhj'
import postsAPI from '../api/posts_api'
+import MediaPreview from './MediaPreview'
import '../style/HomeFeed.css'
const categories = [
@@ -59,9 +60,10 @@
title: d.title,
author: `作者 ${d.user_id}`,
avatar: `https://i.pravatar.cc/40?img=${d.user_id}`,
- img: d.media_urls?.[0] || '',
+ media: d.media_urls?.[0] || '', // 改为 media,支持图片和视频
likes: d.heat,
- content: d.content || ''
+ content: d.content || '',
+ mediaUrls: d.media_urls || [] // 保存所有媒体URL
}
} catch {
return {
@@ -69,9 +71,10 @@
title: item.title,
author: item.author || '佚名',
avatar: `https://i.pravatar.cc/40?img=${item.id}`,
- img: item.img || '',
+ media: item.img || '',
likes: item.heat || 0,
- content: item.content || ''
+ content: item.content || '',
+ mediaUrls: []
}
}
})
@@ -101,9 +104,10 @@
title: d.title,
author: `作者 ${d.user_id}`,
avatar: `https://i.pravatar.cc/40?img=${d.user_id}`,
- img: d.media_urls?.[0] || '',
+ media: d.media_urls?.[0] || '', // 改为 media,支持图片和视频
likes: d.heat,
- content: d.content || ''
+ content: d.content || '',
+ mediaUrls: d.media_urls || [] // 保存所有媒体URL
}
} catch {
// 拉详情失败时兜底
@@ -112,9 +116,10 @@
title: item.title,
author: item.author || '佚名',
avatar: `https://i.pravatar.cc/40?img=${item.id}`,
- img: item.img || '',
+ media: item.img || '',
likes: item.heat || 0,
- content: item.content || ''
+ content: item.content || '',
+ mediaUrls: []
}
}
})
@@ -144,9 +149,10 @@
title: d.title,
author: `作者 ${d.user_id}`,
avatar: `https://i.pravatar.cc/40?img=${d.user_id}`,
- img: d.media_urls?.[0] || '',
+ media: d.media_urls?.[0] || '', // 改为 media,支持图片和视频
likes: d.heat,
- content: d.content || ''
+ content: d.content || '',
+ mediaUrls: d.media_urls || [] // 保存所有媒体URL
}
} catch {
// 拉详情失败时兜底
@@ -155,9 +161,10 @@
title: item.title,
author: item.author || '佚名',
avatar: `https://i.pravatar.cc/40?img=${item.id}`,
- img: item.img || '',
+ media: item.img || '',
likes: item.heat || 0,
- content: item.content || ''
+ content: item.content || '',
+ mediaUrls: []
}
}
})
@@ -187,9 +194,10 @@
title: d.title,
author: `作者 ${d.user_id}`,
avatar: `https://i.pravatar.cc/40?img=${d.user_id}`,
- img: d.media_urls?.[0] || '',
+ media: d.media_urls?.[0] || '', // 改为 media,支持图片和视频
likes: d.heat,
- content: d.content || ''
+ content: d.content || '',
+ mediaUrls: d.media_urls || [] // 保存所有媒体URL
}
} catch {
return {
@@ -197,9 +205,10 @@
title: item.title,
author: item.author || '佚名',
avatar: `https://i.pravatar.cc/40?img=${item.id}`,
- img: item.img || '',
+ media: item.img || '',
likes: item.heat || 0,
- content: item.content || ''
+ content: item.content || '',
+ mediaUrls: []
}
}
})
@@ -260,9 +269,11 @@
id: d.id,
title: d.title,
author: `作者 ${d.user_id}`,
- // avatar: `https://i.pravatar.cc/40?img=${d.user_id}`,
- img: d.media_urls?.[0] || '', // 用第一张媒体作为封面
- likes: d.heat
+ authorId: d.user_id,
+ avatar: `http://192.168.5.200:8080/static/profile.webp`,
+ media: d.media_urls?.[0] || '', // 改为 media,支持图片和视频
+ likes: d.heat,
+ mediaUrls: d.media_urls || [] // 保存所有媒体URL
}
})
)
@@ -304,6 +315,8 @@
}
}
+ const [previewImg, setPreviewImg] = useState(null)
+
const handlePostClick = (postId) => {
navigate(`/post/${postId}`)
}
@@ -431,7 +444,20 @@
) : (
items.map(item => (
<div key={item.id} className="feed-card" onClick={() => handlePostClick(item.id)}>
- {item.img && <img className="card-img" src={item.img} alt={item.title} />}
+ {item.media && (
+ <MediaPreview
+ url={item.media}
+ alt={item.title}
+ className="card-img"
+ onClick={(url) => {
+ // 对于图片,显示预览
+ if (!url.toLowerCase().includes('video') && !url.includes('.mp4') && !url.includes('.webm')) {
+ setPreviewImg(url)
+ }
+ }}
+ style={{ cursor: 'pointer' }}
+ />
+ )}
<h3 className="card-title">{item.title}</h3>
{item.content && <div className="card-content">{item.content.slice(0, 60) || ''}</div>}
<div className="card-footer">
@@ -449,6 +475,37 @@
)}
</div>
)}
+
+ {/* 图片预览弹窗 */}
+ {previewImg && (
+ <div
+ className="img-preview-mask"
+ style={{
+ position: 'fixed',
+ zIndex: 9999,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ background: 'rgba(0,0,0,0.7)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ onClick={() => setPreviewImg(null)}
+ >
+ <img
+ src={previewImg}
+ alt="大图预览"
+ style={{
+ maxWidth: '90vw',
+ maxHeight: '90vh',
+ borderRadius: 12,
+ boxShadow: '0 4px 24px #0008'
+ }}
+ />
+ </div>
+ )}
</div>
)
}
diff --git a/Merge/front/src/components/MediaPreview.jsx b/Merge/front/src/components/MediaPreview.jsx
new file mode 100644
index 0000000..3f0c6e2
--- /dev/null
+++ b/Merge/front/src/components/MediaPreview.jsx
@@ -0,0 +1,119 @@
+import React, { useState } from 'react'
+import VideoPreview from './VideoPreview'
+import { Play } from 'lucide-react'
+
+// 判断文件是否为视频
+const isVideoFile = (url) => {
+ if (!url) return false
+ const videoExtensions = ['.mp4', '.webm', '.ogg', '.avi', '.mov', '.wmv', '.flv', '.mkv']
+ const lowerUrl = url.toLowerCase()
+ return videoExtensions.some(ext => lowerUrl.includes(ext)) || lowerUrl.includes('video')
+}
+
+// 媒体预览组件(支持图片和视频)
+const MediaPreview = ({
+ url,
+ alt = '',
+ className = '',
+ style = {},
+ onClick = null,
+ showPlayIcon = true,
+ maxWidth = 220,
+ maxHeight = 220
+}) => {
+ const [showVideoPreview, setShowVideoPreview] = useState(false)
+
+ const handleMediaClick = () => {
+ if (isVideoFile(url)) {
+ setShowVideoPreview(true)
+ } else if (onClick) {
+ onClick(url)
+ }
+ }
+
+ const defaultStyle = {
+ maxWidth,
+ maxHeight,
+ borderRadius: 8,
+ objectFit: 'cover',
+ cursor: 'pointer',
+ ...style
+ }
+
+ if (isVideoFile(url)) {
+ return (
+ <>
+ <div style={{ position: 'relative', ...defaultStyle }} onClick={handleMediaClick}>
+ <video
+ src={url}
+ style={defaultStyle}
+ preload="metadata"
+ muted
+ />
+ {showPlayIcon && (
+ <div style={{
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ background: 'rgba(0,0,0,0.6)',
+ borderRadius: '50%',
+ padding: 12,
+ color: 'white'
+ }}>
+ <Play size={24} fill="white" />
+ </div>
+ )}
+ </div>
+
+ {/* 视频预览弹窗 */}
+ {showVideoPreview && (
+ <div
+ style={{
+ position: 'fixed',
+ zIndex: 9999,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ background: 'rgba(0,0,0,0.8)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20
+ }}
+ onClick={() => setShowVideoPreview(false)}
+ >
+ <div
+ style={{
+ maxWidth: '90vw',
+ maxHeight: '90vh',
+ width: 'auto',
+ height: 'auto'
+ }}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <VideoPreview
+ src={url}
+ onClose={() => setShowVideoPreview(false)}
+ style={{ borderRadius: 12, overflow: 'hidden' }}
+ />
+ </div>
+ </div>
+ )}
+ </>
+ )
+ }
+
+ return (
+ <img
+ src={url}
+ alt={alt}
+ className={className}
+ style={defaultStyle}
+ onClick={handleMediaClick}
+ />
+ )
+}
+
+export default MediaPreview
diff --git a/Merge/front/src/components/PostDetailJWLLL.jsx b/Merge/front/src/components/PostDetailJWLLL.jsx
index d598a72..01f64b5 100644
--- a/Merge/front/src/components/PostDetailJWLLL.jsx
+++ b/Merge/front/src/components/PostDetailJWLLL.jsx
@@ -5,6 +5,7 @@
import { getUserInfo } from '../utils/auth'
import FollowButton from './FollowButton'
import postsAPI from '../api/posts_api'
+import MediaPreview from './MediaPreview'
import '../style/PostDetail.css'
import dayjs from 'dayjs'
@@ -314,11 +315,24 @@
</div>
)}
- {/* 帖子图片(支持多图) */}
+ {/* 帖子媒体(支持多图/多视频) */}
{Array.isArray(post.media_urls) && post.media_urls.length > 0 && (
- <div className="post-images" style={{display:'flex',gap:8,marginBottom:16}}>
+ <div className="post-media" style={{display:'flex',gap:8,marginBottom:16,flexWrap:'wrap'}}>
{post.media_urls.map((url, idx) => (
- <img key={idx} src={url} alt={`图片${idx+1}`} style={{maxWidth:220,maxHeight:220,borderRadius:8,objectFit:'cover',cursor:'pointer'}} onClick={() => setPreviewImg(url)} />
+ <MediaPreview
+ key={idx}
+ url={url}
+ alt={`媒体${idx+1}`}
+ onClick={(mediaUrl) => {
+ // 对于图片,显示预览
+ if (!mediaUrl.toLowerCase().includes('video') && !mediaUrl.includes('.mp4') && !mediaUrl.includes('.webm')) {
+ setPreviewImg(mediaUrl)
+ }
+ }}
+ style={{ cursor: 'pointer' }}
+ maxWidth={320}
+ maxHeight={320}
+ />
))}
</div>
)}
diff --git a/Merge/front/src/components/UploadPage.jsx b/Merge/front/src/components/UploadPage.jsx
index 817a210..405aeb8 100644
--- a/Merge/front/src/components/UploadPage.jsx
+++ b/Merge/front/src/components/UploadPage.jsx
@@ -170,6 +170,17 @@
<div className="file-thumbnail">
<img src={URL.createObjectURL(file)} alt={file.name} />
</div>
+ ) : file.type.startsWith('video/') ? (
+ <div className="file-thumbnail video-thumbnail">
+ <video
+ src={URL.createObjectURL(file)}
+ muted
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
+ />
+ <div className="video-overlay">
+ <Video size={24} />
+ </div>
+ </div>
) : (
<div className="file-thumbnail video-thumbnail">
<Video size={24} />
diff --git a/Merge/front/src/components/UserProfile.jsx b/Merge/front/src/components/UserProfile.jsx
index c957473..2e54fac 100644
--- a/Merge/front/src/components/UserProfile.jsx
+++ b/Merge/front/src/components/UserProfile.jsx
@@ -53,7 +53,10 @@
Collections,
ChevronLeft,
ChevronRight,
- Close
+ Close,
+ Bookmark,
+ Group,
+ People
} from '@mui/icons-material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { Link, useNavigate } from 'react-router-dom';
@@ -66,7 +69,10 @@
followUser as followUserApi,
unfollowUser as unfollowUserApi,
getUserPosts,
- getUserInteractions
+ getUserInteractions,
+ getUserFollowers,
+ getFavorites,
+ getUserFollowing
} from '../api/api_ljc';
import { fetchPost } from '../api/posts_wzy';
@@ -119,6 +125,10 @@
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(0);
const [isEditing, setIsEditing] = useState(false);
+ const [favorites, setFavorites] = useState([]);
+ const [following, setFollowing] = useState([]);
+ const [followers, setFollowers] = useState([]);
+ const [follower, setFollower] = useState([]);
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' });
const [anchorEl, setAnchorEl] = useState(null);
@@ -199,35 +209,13 @@
fetchInteractions();
- const handleFollowUser = async (followeeId) => {
- try {
- await followUserApi(followeeId);
- showSnackbar('关注成功');
-
- // 更新粉丝列表状态(将刚关注的用户标记为已关注)
- // setFollowers(prev => prev.map(user =>
- // user.id === followeeId ? { ...user, is_following: true } : user
- // ));
-
- // 更新当前用户关注数
- if (currentUser) {
- setCurrentUser(prev => ({
- ...prev,
- following_count: prev.following_count + 1
- }));
- }
-
- } catch (error) {
- console.error('关注操作失败:', error);
- showSnackbar('关注失败,请重试', 'error');
- }
- };
+
const fetchData = async () => {
try {
setLoading(true);
// 获取当前登录用户
- const currentUserRes = await getCurrentUser();
+ const currentUserRes = await getUser(userId);
setCurrentUser(currentUserRes.data);
// 获取目标用户信息
@@ -241,6 +229,22 @@
birthday: profileUserRes.data.birthday || '',
location: profileUserRes.data.location || ''
});
+
+ if (activeTab === 1) {
+ // 加载收藏数据
+ const favoritesRes = await getFavorites(userId);
+ setFavorites(favoritesRes.data);
+ } else if (activeTab === 2) {
+ // 加载关注列表
+ const followingRes = await getUserFollowing(userId);
+ setFollowing(followingRes.data);
+ console.log("following",followingRes.data)
+ } else if (activeTab === 3) {
+ // 加载粉丝列表
+ const followersRes = await getUserFollowers(userId);
+ //
+ setFollowers(followersRes.data.data);
+ }
// 获取用户帖子
const postsRes = await getUserPosts(userId);
@@ -283,7 +287,7 @@
};
fetchData();
- }, [userId]);
+ }, [activeTab,userId]);
// 根据标签页加载数据
useEffect(() => {
@@ -314,18 +318,40 @@
}
};
- const handleFollowUser = async (followeeId) => {
+ const handleFollowUser = async (userId,followeeId) => {
try {
- await followUserApi(followeeId);
+ await followUserApi(userId,followeeId);
showSnackbar('关注成功');
- // 更新当前用户关注数
- if (currentUser) {
- setCurrentUser(prev => ({
- ...prev,
- following_count: prev.following_count + 1
- }));
- }
+ // 更新粉丝列表中的关注状态
+ setFollowers(prevFollowers =>
+ prevFollowers.map(follower =>
+ follower.id === followeeId
+ ? { ...follower, is_following: true }
+ : follower
+ )
+ );
+
+ // 更新当前用户的关注数
+ if (currentUser) {
+ setCurrentUser(prev => ({
+ ...prev,
+ following_count: prev.following_count + 1
+ }));
+ }
+
+ // 如果被关注的用户是当前用户的粉丝,更新粉丝数
+ setFollowers(prevFollowers =>
+ prevFollowers.map(follower =>
+ follower.id === followeeId
+ ? {
+ ...follower,
+ followers_count: follower.followers_count + 1
+ }
+ : follower
+ )
+ );
+
} catch (error) {
console.error('关注操作失败:', error);
@@ -333,13 +359,27 @@
}
};
- const handleUnfollow = async (followeeId, e) => {
- e.stopPropagation(); // 阻止事件冒泡
+ const handleUnfollow = async (userId,followeeId, e) => {
+ // e.stopPropagation(); // 阻止事件冒泡
try {
- await unfollowUserApi(followeeId);
+ await unfollowUserApi(userId,followeeId);
showSnackbar('已取消关注');
+ // 更新关注列表 - 移除取消关注的用户
+ setFollowing(prevFollowing =>
+ prevFollowing.filter(user => user.id !== followeeId)
+ );
+
+ // 更新粉丝列表 - 更新关注状态
+ setFollowers(prevFollowers =>
+ prevFollowers.map(follower =>
+ follower.id === followeeId
+ ? { ...follower, is_following: false }
+ : follower
+ )
+ );
+
// 更新当前用户关注数
if (currentUser) {
setCurrentUser(prev => ({
@@ -441,6 +481,7 @@
);
}
+ console.log(currentUser.id)
const isOwnProfile = currentUser && currentUser.id === parseInt(userId);
return (
@@ -616,52 +657,32 @@
}}
>
<Tab icon={isMobile ? <Collections /> : null} label="笔记" />
+ <Tab icon={isMobile ? <Bookmark /> : null} label="收藏" />
+ <Tab icon={isMobile ? <Group /> : null} label="关注" />
+ <Tab icon={isMobile ? <People /> : null} label="粉丝" />
</Tabs>
</Box>
{/* 内容区域 */}
<Box sx={{ mt: 3 }}>
- {/* 只保留笔记标签页 */}
- <Grid container spacing={3}>
- {tabLoading ? (
- <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
- <CircularProgress />
- </Grid>
- ) : allPosts.length === 0 ? (
- <Grid item xs={12}>
- <Box sx={{
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- py: 8,
- textAlign: 'center'
- }}>
- <Collections sx={{ fontSize: 60, color: 'grey.300', mb: 2 }} />
- <Typography variant="h6" sx={{ mb: 1 }}>
- 还没有发布笔记
- </Typography>
- <Typography variant="body1" color="textSecondary" sx={{ mb: 3 }}>
- {isOwnProfile ? '分享你的生活点滴吧~' : '该用户还没有发布任何笔记'}
- </Typography>
- {isOwnProfile && (
- <Button variant="contained" color="primary">
- 发布第一篇笔记
- </Button>
- )}
- </Box>
- </Grid>
- ) : (
- // 显示当前页的帖子
- posts.map((post, index) => (
- <Grid item xs={12} sm={6} lg={3} key={post.id}>
- <Card elevation={0} sx={{
- bgcolor: 'white',
- borderRadius: 3,
- height: '100%',
- display: 'flex',
- flexDirection: 'column'
- }}>
- {/* 只有当帖子有 media_urls 时才显示图片 */}
+
+ {activeTab === 0 && (
+ <Grid container spacing={3}>
+ {tabLoading ? (
+ <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
+ <CircularProgress />
+ </Grid>
+ ) : posts.length > 0 ? (
+ posts.map((post, index) => (
+ <Grid item xs={12} sm={6} lg={3} key={post.id}>
+ <Card elevation={0} sx={{
+ bgcolor: 'white',
+ borderRadius: 3,
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column'
+ }}>
+ {/* 只有当帖子有 media_urls 时才显示图片 */}
{post.media_urls && post.media_urls.length > 0 && (
<CardMedia
component="img"
@@ -670,63 +691,322 @@
alt={post.title}
/>
)}
- <CardContent sx={{ flexGrow: 1 }}>
- <Typography gutterBottom variant="h6" component="div">
- {post.title}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {post.content ? post.content.substring(0, 60) + '...' : '暂无内容'}
- </Typography>
- </CardContent>
- <CardActions sx={{ justifyContent: 'space-between', px: 2, pb: 2 }}>
- <Box>
- <IconButton aria-label="add to favorites">
- <Favorite />
- <Typography variant="body2" sx={{ ml: 1 }}>
- {post.heat || Math.floor(Math.random() * 1000) + 1000}
- </Typography>
- </IconButton>
- <IconButton aria-label="share">
- <Share />
- </IconButton>
- </Box>
- <Chip
- label={post.type === 'image' ? '图文' : post.type === 'video' ? '视频' : '文档'}
- size="small"
- color="primary"
- variant="outlined"
- />
- </CardActions>
- </Card>
+ <CardContent sx={{ flexGrow: 1 }}>
+ <Typography gutterBottom variant="h6" component="div">
+ {post.title}
+ </Typography>
+ <Typography variant="body2" color="text.secondary">
+ {post.content.substring(0, 60)}...
+ </Typography>
+ </CardContent>
+ <CardActions sx={{ justifyContent: 'space-between', px: 2, pb: 2 }}>
+ <Box>
+ <IconButton aria-label="add to favorites">
+ <Favorite />
+ <Typography variant="body2" sx={{ ml: 1 }}>
+ {post.heat || Math.floor(Math.random() * 1000) + 1000}
+ </Typography>
+ </IconButton>
+ <IconButton aria-label="share">
+ <Share />
+ </IconButton>
+ </Box>
+ <Chip
+ label={post.type === 'image' ? '图文' : post.type === 'video' ? '视频' : '文档'}
+ size="small"
+ color="primary"
+ variant="outlined"
+ />
+ </CardActions>
+ </Card>
+ </Grid>
+ ))
+ ) : (
+ <Grid item xs={12}>
+ <Box sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ py: 8,
+ textAlign: 'center'
+ }}>
+ <Collections sx={{ fontSize: 60, color: 'grey.300', mb: 2 }} />
+ <Typography variant="h6" sx={{ mb: 1 }}>
+ 还没有发布笔记
+ </Typography>
+ <Typography variant="body1" color="textSecondary" sx={{ mb: 3 }}>
+ {isOwnProfile ? '分享你的生活点滴吧~' : '该用户还没有发布任何笔记'}
+ </Typography>
+ {isOwnProfile && (
+ <Button variant="contained" color="primary">
+ 发布第一篇笔记
+ </Button>
+ )}
+ </Box>
</Grid>
- ))
- )}
-
- {/* 分页组件 */}
- {allPosts.length > postsPerPage && (
- <Grid item xs={12}>
- <Stack spacing={2} alignItems="center" sx={{ mt: 4 }}>
- <Typography variant="body2" color="textSecondary">
- 共 {allPosts.length} 篇笔记,第 {currentPage} 页,共 {totalPages} 页
- </Typography>
- <Pagination
- count={totalPages}
- page={currentPage}
- onChange={(event, page) => handlePageChange(page)}
- color="primary"
- size={isMobile ? "small" : "medium"}
- showFirstButton
- showLastButton
- sx={{
- '& .MuiPaginationItem-root': {
- borderRadius: 2,
+ )}
+
+ {posts.length > 0 && (
+ <Grid item xs={12}>
+ <Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
+ <Button
+ variant="outlined"
+ sx={{
+ borderRadius: 20,
+ px: 4,
+ display: 'flex',
+ alignItems: 'center'
+ }}
+ >
+ <ChevronLeft sx={{ mr: 1 }} />
+ 上一页
+ <ChevronRight sx={{ ml: 2 }} />
+ </Button>
+ </Box>
+ </Grid>
+ )}
+ </Grid>
+ )}
+
+ {activeTab === 1 && (
+ <Grid container spacing={3}>
+ {tabLoading ? (
+ <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
+ <CircularProgress />
+ </Grid>
+ ) : favorites.length > 0 ? (
+ favorites.map((favorite) => (
+ <Grid item xs={12} sm={6} md={4} lg={3} key={favorite.id}>
+ <Card elevation={0} sx={{
+ bgcolor: 'white',
+ borderRadius: 3,
+ transition: 'transform 0.3s, box-shadow 0.3s',
+ '&:hover': {
+ transform: 'translateY(-5px)',
+ boxShadow: 3
}
- }}
- />
- </Stack>
- </Grid>
- )}
- </Grid>
+ }}>
+ <Box sx={{
+ height: 160,
+ position: 'relative',
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ overflow: 'hidden'
+ }}>
+ <CardMedia
+ component="img"
+ height="160"
+ image={`https://source.unsplash.com/random/400x300?${favorite.id}`}
+ alt={favorite.title}
+ />
+ <Box sx={{
+ position: 'absolute',
+ top: 8,
+ right: 8,
+ bgcolor: 'rgba(0,0,0,0.6)',
+ color: 'white',
+ px: 1,
+ py: 0.5,
+ borderRadius: 4,
+ fontSize: 12
+ }}>
+ {favorite.type === 'image' ? '图文' : favorite.type === 'video' ? '视频' : '文档'}
+ </Box>
+ </Box>
+ <CardContent>
+ <Typography gutterBottom variant="subtitle1" fontWeight="medium">
+ {favorite.title}
+ </Typography>
+ <Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
+ <Box sx={{ display: 'flex', alignItems: 'center' }}>
+ <Favorite fontSize="small" color="error" />
+ <Typography variant="body2" sx={{ ml: 0.5 }}>
+ {favorite.heat || Math.floor(Math.random() * 1000) + 1000}
+ </Typography>
+ </Box>
+ <Box sx={{ display: 'flex', alignItems: 'center' }}>
+ <Bookmark fontSize="small" color="primary" />
+ <Typography variant="body2" sx={{ ml: 0.5 }}>
+ {Math.floor(Math.random() * 500) + 100}
+ </Typography>
+ </Box>
+ </Box>
+ </CardContent>
+ </Card>
+ </Grid>
+ ))
+ ) : (
+ <Grid item xs={12}>
+ <Box sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ py: 8,
+ textAlign: 'center'
+ }}>
+ <Bookmark sx={{ fontSize: 60, color: 'grey.300', mb: 2 }} />
+ <Typography variant="h6" sx={{ mb: 1 }}>
+ {isOwnProfile ? '你还没有收藏内容' : '该用户没有收藏内容'}
+ </Typography>
+ <Typography variant="body1" color="textSecondary">
+ {isOwnProfile ? '看到喜欢的笔记可以收藏起来哦~' : ''}
+ </Typography>
+ </Box>
+ </Grid>
+ )}
+ </Grid>
+ )}
+
+ {activeTab === 2 && (
+ <Grid container spacing={3}>
+ {tabLoading ? (
+ <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
+ <CircularProgress />
+ </Grid>
+ ) : following.length > 0 ? (
+ following.map((follow) => (
+ <Grid item xs={12} sm={6} md={4} key={follow.id}>
+ <Paper
+ elevation={0}
+ sx={{
+ bgcolor: 'white',
+ borderRadius: 3,
+ p: 2,
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ '&:hover': {
+ boxShadow: 1
+ }
+ }}
+ onClick={() => navigateToUserProfile(follow.id)}
+ >
+ <Avatar
+ src={follow.avatar || 'https://randomuser.me/api/portraits/men/22.jpg'}
+ sx={{ width: 60, height: 60 }}
+ />
+ <Box sx={{ ml: 2, flexGrow: 1 }}>
+ <Typography fontWeight="medium">{follow.username}</Typography>
+ <Typography variant="body2" color="textSecondary">
+ {follow.followers_count || Math.floor(Math.random() * 100) + 10} 粉丝
+ </Typography>
+ </Box>
+ {isOwnProfile && (
+ <Button
+ variant="outlined"
+ size="small"
+ sx={{ borderRadius: 20 }}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleUnfollow(userId,follow.id);
+ }}
+ >
+ 已关注
+ </Button>
+ )}
+ </Paper>
+ </Grid>
+ ))
+ ) : (
+ <Grid item xs={12}>
+ <Box sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ py: 8,
+ textAlign: 'center'
+ }}>
+ <Group sx={{ fontSize: 60, color: 'grey.300', mb: 2 }} />
+ <Typography variant="h6" sx={{ mb: 1 }}>
+ {isOwnProfile ? '你还没有关注任何人' : '该用户还没有关注任何人'}
+ </Typography>
+ <Typography variant="body1" color="textSecondary">
+ {isOwnProfile ? '发现有趣的人并关注他们吧~' : ''}
+ </Typography>
+ </Box>
+ </Grid>
+ )}
+ </Grid>
+ )}
+ {activeTab === 3 && (
+ <Grid container spacing={3}>
+ {tabLoading ? (
+ <Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
+ <CircularProgress />
+ </Grid>
+ ) : followers.length > 0 ? (
+ followers.map((follower) => (
+ <Grid item xs={12} sm={6} md={4} key={follower.id}>
+ <Paper
+ elevation={0}
+ sx={{
+ bgcolor: 'white',
+ borderRadius: 3,
+ p: 2,
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ '&:hover': {
+ boxShadow: 1
+ }
+ }}
+ onClick={() => navigateToUserProfile(follower.id)}
+ >
+ <Avatar
+ src={follower.avatar || 'https://randomuser.me/api/portraits/men/22.jpg'}
+ sx={{ width: 60, height: 60 }}
+ />
+ <Box sx={{ ml: 2, flexGrow: 1 }}>
+ <Typography fontWeight="medium">{follower.username}</Typography>
+ <Typography variant="body2" color="textSecondary">
+ {follower.bio || '暂无简介'}
+ </Typography>
+ <Typography variant="body2" color="textSecondary">
+ {follower.followers_count} 粉丝
+ </Typography>
+ </Box>
+ {currentUser && currentUser.id !== follower.id && (
+ <Button
+ variant={follower.is_following ? "outlined" : "contained"}
+ color="primary"
+ size="small"
+ sx={{ borderRadius: 20 }}
+ onClick={(e) => {
+ e.stopPropagation();
+ if (follower.is_following) {
+ handleUnfollow(userId,follower.id);
+ } else {
+ handleFollowUser(userId,follower.id);
+ }
+ }}
+ >
+ {follower.is_following ? '已关注' : '关注'}
+ </Button>
+ )}
+ </Paper>
+ </Grid>
+ ))
+ ) : (
+ <Grid item xs={12}>
+ <Box sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ py: 8,
+ textAlign: 'center'
+ }}>
+ <People sx={{ fontSize: 60, color: 'grey.300', mb: 2 }} />
+ <Typography variant="h6" sx={{ mb: 1 }}>
+ {isOwnProfile ? '你还没有粉丝' : '该用户还没有粉丝'}
+ </Typography>
+ <Typography variant="body1" color="textSecondary">
+ {isOwnProfile ? '分享更多内容来吸引粉丝吧~' : ''}
+ </Typography>
+ </Box>
+ </Grid>
+ )}
+ </Grid>
+ )}
</Box>
</Container>
diff --git a/Merge/front/src/components/VideoPreview.jsx b/Merge/front/src/components/VideoPreview.jsx
new file mode 100644
index 0000000..9183c11
--- /dev/null
+++ b/Merge/front/src/components/VideoPreview.jsx
@@ -0,0 +1,199 @@
+import React, { useState, useRef } from 'react'
+import { Play, Pause, Volume2, VolumeX, Maximize2, X } from 'lucide-react'
+
+const VideoPreview = ({ src, poster, onClose, className = '', style = {} }) => {
+ const videoRef = useRef(null)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [isMuted, setIsMuted] = useState(false)
+ const [isFullscreen, setIsFullscreen] = useState(false)
+ const [duration, setDuration] = useState(0)
+ const [currentTime, setCurrentTime] = useState(0)
+
+ const togglePlay = () => {
+ if (videoRef.current) {
+ if (isPlaying) {
+ videoRef.current.pause()
+ } else {
+ videoRef.current.play()
+ }
+ setIsPlaying(!isPlaying)
+ }
+ }
+
+ const toggleMute = () => {
+ if (videoRef.current) {
+ videoRef.current.muted = !isMuted
+ setIsMuted(!isMuted)
+ }
+ }
+
+ const toggleFullscreen = () => {
+ if (videoRef.current) {
+ if (!isFullscreen) {
+ if (videoRef.current.requestFullscreen) {
+ videoRef.current.requestFullscreen()
+ }
+ } else {
+ if (document.exitFullscreen) {
+ document.exitFullscreen()
+ }
+ }
+ setIsFullscreen(!isFullscreen)
+ }
+ }
+
+ const handleTimeUpdate = () => {
+ if (videoRef.current) {
+ setCurrentTime(videoRef.current.currentTime)
+ }
+ }
+
+ const handleLoadedMetadata = () => {
+ if (videoRef.current) {
+ setDuration(videoRef.current.duration)
+ }
+ }
+
+ const handleSeek = (e) => {
+ if (videoRef.current) {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const clickX = e.clientX - rect.left
+ const newTime = (clickX / rect.width) * duration
+ videoRef.current.currentTime = newTime
+ setCurrentTime(newTime)
+ }
+ }
+
+ const formatTime = (time) => {
+ const minutes = Math.floor(time / 60)
+ const seconds = Math.floor(time % 60)
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`
+ }
+
+ return (
+ <div className={`video-preview ${className}`} style={style}>
+ <div className="video-container" style={{ position: 'relative', borderRadius: 8, overflow: 'hidden' }}>
+ <video
+ ref={videoRef}
+ src={src}
+ poster={poster}
+ onTimeUpdate={handleTimeUpdate}
+ onLoadedMetadata={handleLoadedMetadata}
+ onPlay={() => setIsPlaying(true)}
+ onPause={() => setIsPlaying(false)}
+ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
+ preload="metadata"
+ />
+
+ {/* 视频控制层 */}
+ <div
+ className="video-controls"
+ style={{
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
+ padding: '20px 12px 12px',
+ opacity: 1,
+ transition: 'opacity 0.3s'
+ }}
+ >
+ {/* 进度条 */}
+ <div
+ className="progress-bar"
+ style={{
+ height: 4,
+ background: 'rgba(255,255,255,0.3)',
+ borderRadius: 2,
+ marginBottom: 8,
+ cursor: 'pointer'
+ }}
+ onClick={handleSeek}
+ >
+ <div
+ style={{
+ height: '100%',
+ background: '#fff',
+ borderRadius: 2,
+ width: `${duration ? (currentTime / duration) * 100 : 0}%`,
+ transition: 'width 0.1s'
+ }}
+ />
+ </div>
+
+ {/* 控制按钮 */}
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <button
+ onClick={togglePlay}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'white',
+ cursor: 'pointer',
+ padding: 4,
+ borderRadius: 4
+ }}
+ >
+ {isPlaying ? <Pause size={20} /> : <Play size={20} />}
+ </button>
+
+ <button
+ onClick={toggleMute}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'white',
+ cursor: 'pointer',
+ padding: 4,
+ borderRadius: 4
+ }}
+ >
+ {isMuted ? <VolumeX size={18} /> : <Volume2 size={18} />}
+ </button>
+
+ <span style={{ color: 'white', fontSize: 12 }}>
+ {formatTime(currentTime)} / {formatTime(duration)}
+ </span>
+ </div>
+
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+ <button
+ onClick={toggleFullscreen}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'white',
+ cursor: 'pointer',
+ padding: 4,
+ borderRadius: 4
+ }}
+ >
+ <Maximize2 size={18} />
+ </button>
+
+ {onClose && (
+ <button
+ onClick={onClose}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'white',
+ cursor: 'pointer',
+ padding: 4,
+ borderRadius: 4
+ }}
+ >
+ <X size={18} />
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export default VideoPreview
diff --git a/Merge/front/src/style/UploadPage.css b/Merge/front/src/style/UploadPage.css
index 138b0c1..70a9aea 100644
--- a/Merge/front/src/style/UploadPage.css
+++ b/Merge/front/src/style/UploadPage.css
@@ -68,3 +68,216 @@
.upload-table th {
background: #f5f5f5;
}
+
+/* 文件预览区域 */
+.file-preview-area {
+ background: #fff;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 40px;
+ border: 1px solid #e8eaed;
+}
+
+.preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+}
+
+.preview-title {
+ font-size: 16px;
+ color: #333;
+ margin: 0;
+ font-weight: 500;
+}
+
+.clear-files-btn {
+ background: #ff4757;
+ color: white;
+ padding: 6px 12px;
+ border: none;
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.clear-files-btn:hover {
+ background: #ff3742;
+}
+
+.file-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 16px;
+}
+
+.file-item {
+ position: relative;
+ background: #fff;
+ border: 1px solid #e8eaed;
+ border-radius: 8px;
+ padding: 12px;
+ text-align: center;
+ transition: all 0.2s;
+}
+
+.file-item:hover {
+ border-color: #1890ff;
+ box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
+}
+
+.file-item:hover .remove-file-btn {
+ opacity: 1;
+}
+
+.remove-file-btn {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ background: rgba(255, 71, 87, 0.8);
+ color: white;
+ border: none;
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ font-weight: bold;
+ opacity: 0;
+ transition: all 0.2s;
+ cursor: pointer;
+}
+
+.file-thumbnail {
+ width: 80px;
+ height: 80px;
+ border-radius: 6px;
+ overflow: hidden;
+ margin: 0 auto 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f8f9fa;
+ position: relative;
+}
+
+.file-thumbnail img,
+.file-thumbnail video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.video-thumbnail {
+ color: #666;
+ position: relative;
+}
+
+.video-overlay {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(0, 0, 0, 0.6);
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ pointer-events: none;
+}
+
+.file-info {
+ text-align: center;
+ width: 100%;
+}
+
+.file-name {
+ font-size: 12px;
+ color: #333;
+ margin-bottom: 4px;
+ font-weight: 500;
+ word-break: break-all;
+}
+
+.file-size {
+ font-size: 11px;
+ color: #999;
+}
+
+/* 进度条 */
+.progress-container {
+ margin-top: 20px;
+ width: 100%;
+ max-width: 400px;
+}
+
+.progress-bar {
+ width: 100%;
+ height: 8px;
+ background-color: #f0f0f0;
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+
+.progress-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #1890ff, #40a9ff);
+ transition: width 0.3s ease;
+}
+
+.progress-text {
+ text-align: center;
+ font-size: 12px;
+ color: #666;
+}
+
+/* 上传信息区域 */
+.upload-info {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 20px;
+ margin-top: 40px;
+}
+
+.info-item {
+ background: #f8f9fa;
+ padding: 16px;
+ border-radius: 8px;
+ text-align: center;
+}
+
+.info-title {
+ font-size: 14px;
+ color: #333;
+ margin-bottom: 8px;
+ font-weight: 500;
+}
+
+.info-desc {
+ font-size: 12px;
+ color: #666;
+ margin: 0;
+}
+
+.fade-in {
+ animation: fadeIn 0.5s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}