Revert^2 "11"
f3699a11cda8d3791a1a5fa1da8bd4c7b531feac
Change-Id: I60fde6e6f1fbe86d0c3a337dae17f98061423f03
diff --git a/Merge/back_ljc/app.py b/Merge/back_ljc/app.py
index f672fc3..0e10b68 100644
--- a/Merge/back_ljc/app.py
+++ b/Merge/back_ljc/app.py
@@ -230,9 +230,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 +265,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:
# 获取收藏行为及其关联的帖子
@@ -449,13 +449,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
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/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..802c4b6 100644
--- a/Merge/front/src/components/UserProfile.jsx
+++ b/Merge/front/src/components/UserProfile.jsx
@@ -227,7 +227,7 @@
setLoading(true);
// 获取当前登录用户
- const currentUserRes = await getCurrentUser();
+ const currentUserRes = await getUser(userId);
setCurrentUser(currentUserRes.data);
// 获取目标用户信息
@@ -441,6 +441,7 @@
);
}
+ console.log(currentUser.id)
const isOwnProfile = currentUser && currentUser.id === parseInt(userId);
return (
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);
+ }
+}