Revert^2 "feat: 完整集成JWLLL搜索推荐系统到Merge项目"
This reverts commit 8c2ae427041141a7dfc6f7b1ca1a16e713003130.
Reason for revert: <回退功能,command由jwl实现>
Change-Id: I08cf7f6de082d6a837aa3e59f68787dbf9d4d1e1
diff --git a/Merge/front/src/api/search_jwlll.js b/Merge/front/src/api/search_jwlll.js
new file mode 100644
index 0000000..5ab7eb1
--- /dev/null
+++ b/Merge/front/src/api/search_jwlll.js
@@ -0,0 +1,97 @@
+// 搜索推荐算法相关的API接口
+// 对应 JWLLL 后端服务
+
+const BASE_URL = 'http://127.0.0.1:5000'
+
+// 通用请求函数
+const request = async (url, options = {}) => {
+ try {
+ const response = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ },
+ ...options
+ })
+ return await response.json()
+ } catch (error) {
+ console.error('API请求错误:', error)
+ throw error
+ }
+}
+
+// 搜索API
+export const searchAPI = {
+ // 搜索内容
+ search: async (keyword, category = undefined) => {
+ return await request(`${BASE_URL}/search`, {
+ method: 'POST',
+ body: JSON.stringify({ keyword, category })
+ })
+ },
+
+ // 获取用户标签
+ getUserTags: async (userId) => {
+ return await request(`${BASE_URL}/user_tags?user_id=${userId}`)
+ },
+
+ // 标签推荐
+ recommendByTags: async (userId, tags) => {
+ return await request(`${BASE_URL}/recommend_tags`, {
+ method: 'POST',
+ body: JSON.stringify({ user_id: userId, tags })
+ })
+ },
+
+ // 协同过滤推荐
+ userBasedRecommend: async (userId, topN = 20) => {
+ return await request(`${BASE_URL}/user_based_recommend`, {
+ method: 'POST',
+ body: JSON.stringify({ user_id: userId, top_n: topN })
+ })
+ },
+
+ // 获取帖子详情
+ getPostDetail: async (postId) => {
+ return await request(`${BASE_URL}/post/${postId}`)
+ },
+
+ // 点赞帖子
+ likePost: async (postId, userId) => {
+ return await request(`${BASE_URL}/like`, {
+ method: 'POST',
+ body: JSON.stringify({ post_id: postId, user_id: userId })
+ })
+ },
+
+ // 取消点赞
+ unlikePost: async (postId, userId) => {
+ return await request(`${BASE_URL}/unlike`, {
+ method: 'POST',
+ body: JSON.stringify({ post_id: postId, user_id: userId })
+ })
+ },
+
+ // 添加评论
+ addComment: async (postId, userId, content) => {
+ return await request(`${BASE_URL}/comment`, {
+ method: 'POST',
+ body: JSON.stringify({ post_id: postId, user_id: userId, content })
+ })
+ },
+
+ // 获取评论
+ getComments: async (postId) => {
+ return await request(`${BASE_URL}/comments/${postId}`)
+ },
+
+ // 上传帖子
+ uploadPost: async (postData) => {
+ return await request(`${BASE_URL}/upload`, {
+ method: 'POST',
+ body: JSON.stringify(postData)
+ })
+ }
+}
+
+export default searchAPI
diff --git a/Merge/front/src/components/CreatePost.jsx b/Merge/front/src/components/CreatePost.jsx
index 1d2f306..c11e247 100644
--- a/Merge/front/src/components/CreatePost.jsx
+++ b/Merge/front/src/components/CreatePost.jsx
@@ -13,10 +13,8 @@
const navigate = useNavigate()
const { postId } = useParams()
const isEdit = Boolean(postId)
-
// 步骤:新帖先上传,编辑则直接到 detail
const [step, setStep] = useState(isEdit ? 'detail' : 'upload')
- const [files, setFiles] = useState([])
const [mediaUrls, setMediaUrls] = useState([])
// 表单字段
@@ -53,10 +51,8 @@
.catch(err => setError(err.message))
.finally(() => setLoading(false))
}, [isEdit, postId])
-
// 上传回调
const handleUploadComplete = async uploadedFiles => {
- setFiles(uploadedFiles)
// TODO: 真正上传到服务器后替换为服务端 URL
const urls = await Promise.all(
uploadedFiles.map(f => URL.createObjectURL(f))
diff --git a/Merge/front/src/components/HomeFeed.jsx b/Merge/front/src/components/HomeFeed.jsx
index c681858..e32a2eb 100644
--- a/Merge/front/src/components/HomeFeed.jsx
+++ b/Merge/front/src/components/HomeFeed.jsx
@@ -1,9 +1,10 @@
// src/components/HomeFeed.jsx
-import React, { useState, useEffect } from 'react'
+import React, { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { ThumbsUp } from 'lucide-react'
import { fetchPosts, fetchPost } from '../api/posts_wzy'
+import { searchAPI } from '../api/search_jwlll'
import '../style/HomeFeed.css'
const categories = [
@@ -11,15 +12,120 @@
'职场','情感','家居','游戏','旅行','健身'
]
+const recommendModes = [
+ { label: '标签推荐', value: 'tag' },
+ { label: '协同过滤推荐', value: 'cf' }
+]
+
+const DEFAULT_USER_ID = '3' // 确保数据库有此用户
+const DEFAULT_TAGS = ['美食','影视','穿搭'] // 可根据实际数据库调整
+
export default function HomeFeed() {
const navigate = useNavigate()
const [activeCat, setActiveCat] = useState('推荐')
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ // JWLLL 搜索推荐相关状态
+ const [search, setSearch] = useState('')
+ const [recMode, setRecMode] = useState('tag')
+ const [recCFNum, setRecCFNum] = useState(20)
+ const [useSearchRecommend, setUseSearchRecommend] = useState(false) // 是否使用搜索推荐模式 // JWLLL 搜索推荐功能函数
+
+ // JWLLL搜索推荐内容
+ const fetchSearchContent = useCallback(async (keyword = '') => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await searchAPI.search(keyword || activeCat, activeCat === '推荐' ? undefined : activeCat)
+ const formattedItems = (data.results || []).map(item => ({
+ id: item.id,
+ title: item.title,
+ author: item.author || '佚名',
+ avatar: `https://i.pravatar.cc/40?img=${item.id}`,
+ img: item.img || '',
+ likes: item.heat || 0,
+ content: item.content
+ }))
+ setItems(formattedItems)
+ } catch (e) {
+ console.error('搜索失败:', e)
+ setError('搜索失败')
+ setItems([])
+ }
+ setLoading(false)
+ }, [activeCat])
+
+ // 标签推荐
+ const fetchTagRecommend = useCallback(async (tags) => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await searchAPI.recommendByTags(DEFAULT_USER_ID, tags)
+ const formattedItems = (data.recommendations || []).map(item => ({
+ id: item.id,
+ title: item.title,
+ author: item.author || '佚名',
+ avatar: `https://i.pravatar.cc/40?img=${item.id}`,
+ img: item.img || '',
+ likes: item.heat || 0,
+ content: item.content
+ }))
+ setItems(formattedItems)
+ } catch (e) {
+ console.error('标签推荐失败:', e)
+ setError('标签推荐失败')
+ setItems([])
+ }
+ setLoading(false)
+ }, [])
+
+ // 协同过滤推荐
+ const fetchCFRecommend = useCallback(async (topN = recCFNum) => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await searchAPI.userBasedRecommend(DEFAULT_USER_ID, topN)
+ const formattedItems = (data.recommendations || []).map(item => ({
+ id: item.id,
+ title: item.title,
+ author: item.author || '佚名',
+ avatar: `https://i.pravatar.cc/40?img=${item.id}`,
+ img: item.img || '',
+ likes: item.heat || 0,
+ content: item.content
+ }))
+ setItems(formattedItems)
+ } catch (e) {
+ console.error('协同过滤推荐失败:', e)
+ setError('协同过滤推荐失败')
+ setItems([])
+ }
+ setLoading(false)
+ }, [recCFNum])
+
+ // 获取用户兴趣标签后再推荐
+ const fetchUserTagsAndRecommend = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ let tags = []
+ try {
+ const data = await searchAPI.getUserTags(DEFAULT_USER_ID)
+ tags = Array.isArray(data.tags) && data.tags.length > 0 ? data.tags : DEFAULT_TAGS
+ } catch {
+ tags = DEFAULT_TAGS
+ }
+ if (recMode === 'tag') {
+ await fetchTagRecommend(tags)
+ } else {
+ await fetchCFRecommend()
+ }
+ setLoading(false)
+ }, [recMode, fetchTagRecommend, fetchCFRecommend])
useEffect(() => {
- async function loadPosts() {
+ // 原始数据加载函数
+ const loadPosts = async () => {
try {
const list = await fetchPosts() // [{id, title, heat, created_at}, …]
// 为了拿到 media_urls 和 user_id,这里再拉详情
@@ -43,25 +149,149 @@
setLoading(false)
}
}
- loadPosts()
- }, [])
+ // 根据模式选择加载方式
+ if (activeCat === '推荐' && useSearchRecommend) {
+ fetchUserTagsAndRecommend()
+ } else {
+ loadPosts()
+ }
+ }, [activeCat, useSearchRecommend, fetchUserTagsAndRecommend])
+ // 切换推荐模式时的额外处理
+ useEffect(() => {
+ if (activeCat === '推荐' && useSearchRecommend) {
+ fetchUserTagsAndRecommend()
+ }
+ // eslint-disable-next-line
+ }, [recMode, fetchUserTagsAndRecommend])
+
+ // 根据模式选择不同的加载方式
+ const handleSearch = e => {
+ e.preventDefault()
+ if (useSearchRecommend) {
+ fetchSearchContent(search)
+ } else {
+ // 切换到搜索推荐模式
+ setUseSearchRecommend(true)
+ fetchSearchContent(search)
+ }
+ }
+
+ const handlePostClick = (postId) => {
+ navigate(`/post/${postId}`)
+ }
return (
<div className="home-feed">
+ {/* 数据源切换 */}
+ <div style={{marginBottom:12, display:'flex', alignItems:'center', gap:16}}>
+ <span>数据源:</span>
+ <div style={{display:'flex', gap:8}}>
+ <button
+ className={!useSearchRecommend ? 'rec-btn styled active' : 'rec-btn styled'}
+ onClick={() => {setUseSearchRecommend(false); setActiveCat('推荐')}}
+ type="button"
+ style={{
+ borderRadius: 20,
+ padding: '4px 18px',
+ border: !useSearchRecommend ? '2px solid #e84c4a' : '1px solid #ccc',
+ background: !useSearchRecommend ? '#fff0f0' : '#fff',
+ color: !useSearchRecommend ? '#e84c4a' : '#333',
+ fontWeight: !useSearchRecommend ? 600 : 400,
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ outline: 'none',
+ }}
+ >原始数据</button>
+ <button
+ className={useSearchRecommend ? 'rec-btn styled active' : 'rec-btn styled'}
+ onClick={() => {setUseSearchRecommend(true); setActiveCat('推荐')}}
+ type="button"
+ style={{
+ borderRadius: 20,
+ padding: '4px 18px',
+ border: useSearchRecommend ? '2px solid #e84c4a' : '1px solid #ccc',
+ background: useSearchRecommend ? '#fff0f0' : '#fff',
+ color: useSearchRecommend ? '#e84c4a' : '#333',
+ fontWeight: useSearchRecommend ? 600 : 400,
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ outline: 'none',
+ }}
+ >智能推荐</button>
+ </div>
+ </div>
+
+ {/* 推荐模式切换,仅在推荐页显示且使用搜索推荐时 */}
+ {activeCat === '推荐' && useSearchRecommend && (
+ <div style={{marginBottom:12, display:'flex', alignItems:'center', gap:16}}>
+ <span style={{marginRight:8}}>推荐模式:</span>
+ <div style={{display:'flex', gap:8}}>
+ {recommendModes.map(m => (
+ <button
+ key={m.value}
+ className={recMode===m.value? 'rec-btn styled active':'rec-btn styled'}
+ onClick={() => setRecMode(m.value)}
+ type="button"
+ style={{
+ borderRadius: 20,
+ padding: '4px 18px',
+ border: recMode===m.value ? '2px solid #e84c4a' : '1px solid #ccc',
+ background: recMode===m.value ? '#fff0f0' : '#fff',
+ color: recMode===m.value ? '#e84c4a' : '#333',
+ fontWeight: recMode===m.value ? 600 : 400,
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ outline: 'none',
+ }}
+ >{m.label}</button>
+ ))}
+ </div>
+ {/* 协同过滤推荐数量选择 */}
+ {recMode === 'cf' && (
+ <div style={{display:'flex',alignItems:'center',gap:4}}>
+ <span>推荐数量:</span>
+ <select value={recCFNum} onChange={e => { setRecCFNum(Number(e.target.value)); fetchCFRecommend(Number(e.target.value)) }} style={{padding:'2px 8px',borderRadius:6,border:'1px solid #ccc'}}>
+ {[10, 20, 30, 50].map(n => <option key={n} value={n}>{n}</option>)}
+ </select>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 搜索栏 */}
+ <form className="feed-search" onSubmit={handleSearch} style={{marginBottom:16, display:'flex', gap:8, alignItems:'center'}}>
+ <input
+ type="text"
+ className="search-input"
+ placeholder="搜索内容/标题/标签"
+ value={search}
+ onChange={e => setSearch(e.target.value)}
+ />
+ <button type="submit" className="search-btn">搜索</button>
+ </form>
+
{/* 顶部分类 */}
<nav className="feed-tabs">
{categories.map(cat => (
<button
key={cat}
className={cat === activeCat ? 'tab active' : 'tab'}
- onClick={() => setActiveCat(cat)}
+ onClick={() => {
+ setActiveCat(cat);
+ setSearch('');
+ if (useSearchRecommend) {
+ if (cat === '推荐') {
+ fetchUserTagsAndRecommend()
+ } else {
+ fetchSearchContent()
+ }
+ }
+ }}
>
{cat}
</button>
))}
- </nav>
-
- {/* 状态提示 */}
+ </nav> {/* 状态提示 */}
{loading ? (
<div className="loading">加载中…</div>
) : error ? (
@@ -69,22 +299,27 @@
) : (
/* 瀑布流卡片区 */
<div className="feed-grid">
- {items.map(item => (
- <div key={item.id} className="feed-card">
- <img className="card-img" src={item.img} alt={item.title} />
- <h3 className="card-title">{item.title}</h3>
- <div className="card-footer">
- <div className="card-author">
- <img className="avatar" src={item.avatar} alt={item.author} />
- <span className="username">{item.author}</span>
- </div>
- <div className="card-likes">
- <ThumbsUp size={16} />
- <span className="likes-count">{item.likes}</span>
+ {items.length === 0 ? (
+ <div style={{padding:32, color:'#aaa'}}>暂无内容</div>
+ ) : (
+ 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} />}
+ <h3 className="card-title">{item.title}</h3>
+ {item.content && <div className="card-content">{item.content.slice(0, 60) || ''}</div>}
+ <div className="card-footer">
+ <div className="card-author">
+ <img className="avatar" src={item.avatar} alt={item.author} />
+ <span className="username">{item.author}</span>
+ </div>
+ <div className="card-likes">
+ <ThumbsUp size={16} />
+ <span className="likes-count">{item.likes}</span>
+ </div>
</div>
</div>
- </div>
- ))}
+ ))
+ )}
</div>
)}
</div>
diff --git a/Merge/front/src/components/LogsDashboard.js b/Merge/front/src/components/LogsDashboard.js
index 22047e2..6ab4746 100644
--- a/Merge/front/src/components/LogsDashboard.js
+++ b/Merge/front/src/components/LogsDashboard.js
@@ -3,7 +3,9 @@
import '../style/Admin.css';
function LogsDashboard() {
+ // eslint-disable-next-line no-unused-vars
const [logs, setLogs] = useState([]);
+ // eslint-disable-next-line no-unused-vars
const [stats, setStats] = useState({});
useEffect(() => {
diff --git a/Merge/front/src/components/PostDetailJWLLL.jsx b/Merge/front/src/components/PostDetailJWLLL.jsx
new file mode 100644
index 0000000..0dc7289
--- /dev/null
+++ b/Merge/front/src/components/PostDetailJWLLL.jsx
@@ -0,0 +1,322 @@
+import React, { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { ArrowLeft, ThumbsUp, MessageCircle, Share2, BookmarkPlus, Heart, Eye } from 'lucide-react'
+import { searchAPI } from '../api/search_jwlll'
+import '../style/PostDetail.css'
+
+export default function PostDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const [post, setPost] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [liked, setLiked] = useState(false)
+ const [bookmarked, setBookmarked] = useState(false)
+ const [likeCount, setLikeCount] = useState(0)
+ const [comments, setComments] = useState([])
+ const [newComment, setNewComment] = useState('')
+ const [showComments, setShowComments] = useState(false)
+
+ const DEFAULT_USER_ID = '3' // 默认用户ID
+
+ useEffect(() => {
+ fetchPostDetail()
+ fetchComments()
+ }, [id])
+
+ const fetchPostDetail = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await searchAPI.getPostDetail(id)
+ setPost(data)
+ setLikeCount(data.heat || 0)
+ } catch (error) {
+ console.error('获取帖子详情失败:', error)
+ setError('帖子不存在或已被删除')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const fetchComments = async () => {
+ try {
+ const data = await searchAPI.getComments(id)
+ setComments(data.comments || [])
+ } catch (error) {
+ console.error('获取评论失败:', error)
+ }
+ }
+
+ const handleBack = () => {
+ navigate(-1)
+ }
+
+ const handleLike = async () => {
+ try {
+ const newLiked = !liked
+ if (newLiked) {
+ await searchAPI.likePost(id, DEFAULT_USER_ID)
+ } else {
+ await searchAPI.unlikePost(id, DEFAULT_USER_ID)
+ }
+ setLiked(newLiked)
+ setLikeCount(prev => newLiked ? prev + 1 : prev - 1)
+ } catch (error) {
+ console.error('点赞失败:', error)
+ // 回滚状态
+ setLiked(!liked)
+ setLikeCount(prev => liked ? prev + 1 : prev - 1)
+ }
+ }
+
+ const handleBookmark = () => {
+ setBookmarked(!bookmarked)
+ // 实际项目中这里应该调用后端API保存收藏状态
+ }
+
+ const handleShare = () => {
+ // 分享功能
+ if (navigator.share) {
+ navigator.share({
+ title: post?.title,
+ text: post?.content,
+ url: window.location.href,
+ })
+ } else {
+ // 复制链接到剪贴板
+ navigator.clipboard.writeText(window.location.href)
+ alert('链接已复制到剪贴板')
+ }
+ }
+
+ const handleAddComment = async (e) => {
+ e.preventDefault()
+ if (!newComment.trim()) return
+
+ try {
+ await searchAPI.addComment(id, DEFAULT_USER_ID, newComment)
+ setNewComment('')
+ fetchComments() // 刷新评论列表
+ } catch (error) {
+ console.error('添加评论失败:', error)
+ alert('评论失败,请重试')
+ }
+ }
+
+ if (loading) {
+ return (
+ <div className="post-detail">
+ <div className="loading-container">
+ <div className="loading-spinner"></div>
+ <p>加载中...</p>
+ </div>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="post-detail">
+ <div className="error-container">
+ <h2>😔 出错了</h2>
+ <p>{error}</p>
+ <button onClick={handleBack} className="back-btn">
+ <ArrowLeft size={20} />
+ 返回
+ </button>
+ </div>
+ </div>
+ )
+ }
+
+ if (!post) {
+ return (
+ <div className="post-detail">
+ <div className="error-container">
+ <h2>😔 帖子不存在</h2>
+ <p>该帖子可能已被删除或不存在</p>
+ <button onClick={handleBack} className="back-btn">
+ <ArrowLeft size={20} />
+ 返回
+ </button>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="post-detail">
+ {/* 顶部导航栏 */}
+ <header className="post-header">
+ <button onClick={handleBack} className="back-btn">
+ <ArrowLeft size={20} />
+ 返回
+ </button>
+ <div className="header-actions">
+ <button onClick={handleShare} className="action-btn">
+ <Share2 size={20} />
+ </button>
+ <button
+ onClick={handleBookmark}
+ className={`action-btn ${bookmarked ? 'active' : ''}`}
+ >
+ <BookmarkPlus size={20} />
+ </button>
+ </div>
+ </header>
+
+ {/* 主要内容区 */}
+ <main className="post-content">
+ {/* 帖子标题 */}
+ <h1 className="post-title">{post.title}</h1>
+
+ {/* 作者信息和元数据 */}
+ <div className="post-meta">
+ <div className="author-info">
+ <div className="avatar">
+ {post.author ? post.author.charAt(0).toUpperCase() : 'U'}
+ </div>
+ <div className="author-details">
+ <span className="author-name">{post.author || '匿名用户'}</span>
+ <span className="post-date">
+ {post.create_time ? new Date(post.create_time).toLocaleDateString('zh-CN') : '未知时间'}
+ </span>
+ </div>
+ </div>
+ <div className="post-stats">
+ <span className="stat-item">
+ <Eye size={16} />
+ {post.views || 0}
+ </span>
+ <span className="stat-item">
+ <Heart size={16} />
+ {likeCount}
+ </span>
+ </div>
+ </div>
+
+ {/* 标签 */}
+ {post.tags && post.tags.length > 0 && (
+ <div className="post-tags">
+ {post.tags.map((tag, index) => (
+ <span key={index} className="tag">{tag}</span>
+ ))}
+ </div>
+ )}
+
+ {/* 帖子正文 */}
+ <div className="post-body">
+ <p>{post.content}</p>
+ </div>
+
+ {/* 类别信息 */}
+ {(post.category || post.type) && (
+ <div className="post-category">
+ {post.category && (
+ <>
+ <span className="category-label">分类:</span>
+ <span className="category-name">{post.category}</span>
+ </>
+ )}
+ {post.type && (
+ <>
+ <span className="category-label" style={{marginLeft: '1em'}}>类型:</span>
+ <span className="category-name">{post.type}</span>
+ </>
+ )}
+ </div>
+ )}
+
+ {/* 评论区 */}
+ <div className="comments-section">
+ <div className="comments-header">
+ <button
+ onClick={() => setShowComments(!showComments)}
+ className="comments-toggle"
+ >
+ <MessageCircle size={20} />
+ 评论 ({comments.length})
+ </button>
+ </div>
+
+ {showComments && (
+ <div className="comments-content">
+ {/* 添加评论 */}
+ <form onSubmit={handleAddComment} className="comment-form">
+ <textarea
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ placeholder="写下你的评论..."
+ className="comment-input"
+ rows={3}
+ />
+ <button type="submit" className="comment-submit">
+ 发布评论
+ </button>
+ </form>
+
+ {/* 评论列表 */}
+ <div className="comments-list">
+ {comments.length === 0 ? (
+ <p className="no-comments">暂无评论</p>
+ ) : (
+ comments.map((comment, index) => (
+ <div key={index} className="comment-item">
+ <div className="comment-author">
+ <div className="comment-avatar">
+ {comment.user_name ? comment.user_name.charAt(0).toUpperCase() : 'U'}
+ </div>
+ <span className="comment-name">{comment.user_name || '匿名用户'}</span>
+ <span className="comment-time">
+ {comment.create_time ? new Date(comment.create_time).toLocaleString('zh-CN') : ''}
+ </span>
+ </div>
+ <div className="comment-content">
+ {comment.content}
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </main>
+
+ {/* 底部操作栏 */}
+ <footer className="post-footer">
+ <div className="action-bar">
+ <button
+ onClick={handleLike}
+ className={`action-button ${liked ? 'liked' : ''}`}
+ >
+ <ThumbsUp size={20} />
+ <span>{likeCount}</span>
+ </button>
+
+ <button
+ onClick={() => setShowComments(!showComments)}
+ className="action-button"
+ >
+ <MessageCircle size={20} />
+ <span>评论</span>
+ </button>
+
+ <button onClick={handleShare} className="action-button">
+ <Share2 size={20} />
+ <span>分享</span>
+ </button>
+
+ <button
+ onClick={handleBookmark}
+ className={`action-button ${bookmarked ? 'bookmarked' : ''}`}
+ >
+ <BookmarkPlus size={20} />
+ <span>收藏</span>
+ </button>
+ </div>
+ </footer>
+ </div>
+ )
+}
diff --git a/Merge/front/src/components/Sidebar.jsx b/Merge/front/src/components/Sidebar.jsx
index 92bc8f1..e35db63 100644
--- a/Merge/front/src/components/Sidebar.jsx
+++ b/Merge/front/src/components/Sidebar.jsx
@@ -7,6 +7,8 @@
Activity,
Users,
ChevronDown,
+ Search,
+ Upload,
} from 'lucide-react'
import '../App.css'
@@ -24,6 +26,7 @@
{ id: 'fans', label: '粉丝数据', path: '/dashboard/fans' },
]
},
+ { id: 'upload-jwlll', label: '智能发布', icon: Upload, path: '/upload-jwlll' },
// { id: 'activity', label: '活动中心', icon: Activity, path: '/activity' },
// { id: 'notes', label: '笔记灵感', icon: BookOpen, path: '/notes' },
// { id: 'creator', label: '创作学院', icon: Users, path: '/creator' },
diff --git a/Merge/front/src/components/UploadPageJWLLL.jsx b/Merge/front/src/components/UploadPageJWLLL.jsx
new file mode 100644
index 0000000..2d9ee7d
--- /dev/null
+++ b/Merge/front/src/components/UploadPageJWLLL.jsx
@@ -0,0 +1,328 @@
+import React, { useState } from 'react'
+import { Image, Video, Send } from 'lucide-react'
+import { searchAPI } from '../api/search_jwlll'
+import '../style/UploadPage.css'
+
+const categories = [
+ '穿搭','美食','彩妆','影视',
+ '职场','情感','家居','游戏','旅行','健身'
+]
+
+export default function UploadPageJWLLL({ onComplete }) {
+ const [activeTab, setActiveTab] = useState('image')
+ const [isDragOver, setIsDragOver] = useState(false)
+ const [isUploading, setIsUploading] = useState(false)
+ const [uploadedFiles, setUploadedFiles] = useState([])
+ const [uploadProgress, setUploadProgress] = useState(0)
+
+ // 新增表单字段
+ const [title, setTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [tags, setTags] = useState('')
+ const [category, setCategory] = useState(categories[0])
+ const [isPublishing, setIsPublishing] = useState(false)
+
+ const DEFAULT_USER_ID = '3' // 默认用户ID
+
+ const validateFiles = files => {
+ const imgTypes = ['image/jpeg','image/jpg','image/png','image/webp']
+ const vidTypes = ['video/mp4','video/mov','video/avi']
+ const types = activeTab==='video'? vidTypes : imgTypes
+ const max = activeTab==='video'? 2*1024*1024*1024 : 32*1024*1024
+
+ const invalid = files.filter(f => !types.includes(f.type) || f.size > max)
+ if (invalid.length) {
+ alert(`发现 ${invalid.length} 个无效文件,请检查文件格式和大小`)
+ return false
+ }
+ return true
+ }
+
+ const simulateUpload = files => {
+ setIsUploading(true)
+ setUploadProgress(0)
+ setUploadedFiles(files)
+ const iv = setInterval(() => {
+ setUploadProgress(p => {
+ if (p >= 100) {
+ clearInterval(iv)
+ setIsUploading(false)
+ if (typeof onComplete === 'function') {
+ onComplete(files)
+ }
+ return 100
+ }
+ return p + 10
+ })
+ }, 200)
+ }
+
+ const handleFileUpload = () => {
+ if (isUploading) return
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = activeTab==='video'? 'video/*' : 'image/*'
+ input.multiple = activeTab==='image'
+ input.onchange = e => {
+ const files = Array.from(e.target.files)
+ if (files.length > 0 && validateFiles(files)) simulateUpload(files)
+ }
+ input.click()
+ }
+
+ const handleDragOver = e => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true) }
+ const handleDragLeave = e => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false) }
+ const handleDrop = e => {
+ e.preventDefault(); e.stopPropagation(); setIsDragOver(false)
+ if (isUploading) return
+ const files = Array.from(e.dataTransfer.files)
+ if (files.length > 0 && validateFiles(files)) simulateUpload(files)
+ }
+
+ const clearFiles = () => setUploadedFiles([])
+ const removeFile = idx => setUploadedFiles(f => f.filter((_,i) => i!==idx))
+
+ // 发布帖子
+ const handlePublish = async () => {
+ if (!title.trim()) {
+ alert('请输入标题')
+ return
+ }
+ if (!content.trim()) {
+ alert('请输入内容')
+ return
+ }
+
+ setIsPublishing(true)
+ try {
+ const postData = {
+ user_id: DEFAULT_USER_ID,
+ title: title.trim(),
+ content: content.trim(),
+ tags: tags.split(',').map(t => t.trim()).filter(t => t),
+ category: category,
+ type: activeTab === 'video' ? 'video' : 'image',
+ media_files: uploadedFiles.map(f => f.name) // 实际项目中应该是上传后的URL
+ }
+
+ await searchAPI.uploadPost(postData)
+ alert('发布成功!')
+
+ // 清空表单
+ setTitle('')
+ setContent('')
+ setTags('')
+ setUploadedFiles([])
+ setActiveTab('image')
+
+ } catch (error) {
+ console.error('发布失败:', error)
+ alert('发布失败,请重试')
+ } finally {
+ setIsPublishing(false)
+ }
+ }
+
+ return (
+ <div className="upload-page-jwlll">
+ <div className="upload-tabs">
+ <button
+ className={`upload-tab${activeTab==='video'?' active':''}`}
+ onClick={() => setActiveTab('video')}
+ >上传视频</button>
+ <button
+ className={`upload-tab${activeTab==='image'?' active':''}`}
+ onClick={() => setActiveTab('image')}
+ >上传图文</button>
+ </div>
+
+ {/* 内容表单 */}
+ <div className="content-form">
+ <div className="form-group">
+ <label htmlFor="title">标题</label>
+ <input
+ id="title"
+ type="text"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ placeholder="请输入标题..."
+ className="form-input"
+ maxLength={100}
+ />
+ </div>
+
+ <div className="form-group">
+ <label htmlFor="content">内容</label>
+ <textarea
+ id="content"
+ value={content}
+ onChange={(e) => setContent(e.target.value)}
+ placeholder="请输入内容..."
+ className="form-textarea"
+ rows={4}
+ maxLength={1000}
+ />
+ </div>
+
+ <div className="form-row">
+ <div className="form-group">
+ <label htmlFor="category">分类</label>
+ <select
+ id="category"
+ value={category}
+ onChange={(e) => setCategory(e.target.value)}
+ className="form-select"
+ >
+ {categories.map(cat => (
+ <option key={cat} value={cat}>{cat}</option>
+ ))}
+ </select>
+ </div>
+
+ <div className="form-group">
+ <label htmlFor="tags">标签</label>
+ <input
+ id="tags"
+ type="text"
+ value={tags}
+ onChange={(e) => setTags(e.target.value)}
+ placeholder="用逗号分隔多个标签..."
+ className="form-input"
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 文件上传区域 */}
+ <div
+ className={`upload-area${isDragOver?' drag-over':''}`}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+ <div className="upload-icon">
+ {activeTab==='video'? <Video/> : <Image/>}
+ </div>
+ <h2 className="upload-title">
+ {activeTab==='video'
+ ? '拖拽视频到此处或点击上传'
+ : '拖拽图片到此处或点击上传'
+ }
+ </h2>
+ <p className="upload-subtitle">(需支持上传格式)</p>
+ <button
+ className={`upload-btn${isUploading?' uploading':''}`}
+ onClick={handleFileUpload}
+ disabled={isUploading}
+ >
+ {isUploading
+ ? `上传中... ${uploadProgress}%`
+ : activeTab==='video'
+ ? '上传视频'
+ : '上传图片'
+ }
+ </button>
+
+ {isUploading && (
+ <div className="progress-container">
+ <div className="progress-bar">
+ <div
+ className="progress-fill"
+ style={{ width: `${uploadProgress}%` }}
+ />
+ </div>
+ <div className="progress-text">{uploadProgress}%</div>
+ </div>
+ )}
+ </div>
+
+ {uploadedFiles.length > 0 && (
+ <div className="file-preview-area">
+ <div className="preview-header">
+ <h3 className="preview-title">已上传文件 ({uploadedFiles.length})</h3>
+ <button className="clear-files-btn" onClick={clearFiles}>
+ 清除所有
+ </button>
+ </div>
+ <div className="file-grid">
+ {uploadedFiles.map((file, i) => (
+ <div key={i} className="file-item">
+ <button
+ className="remove-file-btn"
+ onClick={() => removeFile(i)}
+ title="删除文件"
+ >×</button>
+ {file.type.startsWith('image/') ? (
+ <div className="file-thumbnail">
+ <img src={URL.createObjectURL(file)} alt={file.name} />
+ </div>
+ ) : (
+ <div className="file-thumbnail video-thumbnail">
+ <Video size={24} />
+ </div>
+ )}
+ <div className="file-info">
+ <div className="file-name" title={file.name}>
+ {file.name.length > 20
+ ? file.name.slice(0,17) + '...'
+ : file.name
+ }
+ </div>
+ <div className="file-size">
+ {(file.size/1024/1024).toFixed(2)} MB
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 发布按钮 */}
+ <div className="publish-section">
+ <button
+ className={`publish-btn${isPublishing?' publishing':''}`}
+ onClick={handlePublish}
+ disabled={isPublishing || !title.trim() || !content.trim()}
+ >
+ <Send size={20} />
+ {isPublishing ? '发布中...' : '发布'}
+ </button>
+ </div>
+
+ <div className="upload-info fade-in">
+ {activeTab==='image' ? (
+ <>
+ <div className="info-item">
+ <h3 className="info-title">图片大小</h3>
+ <p className="info-desc">最大32MB</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">图片格式</h3>
+ <p className="info-desc">png/jpg/jpeg/webp</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">分辨率</h3>
+ <p className="info-desc">建议720×960及以上</p>
+ </div>
+ </>
+ ) : (
+ <>
+ <div className="info-item">
+ <h3 className="info-title">视频大小</h3>
+ <p className="info-desc">最大2GB,时长≤5分钟</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">视频格式</h3>
+ <p className="info-desc">mp4/mov</p>
+ </div>
+ <div className="info-item">
+ <h3 className="info-title">分辨率</h3>
+ <p className="info-desc">建议720P及以上</p>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ )
+}
diff --git a/Merge/front/src/router/App.js b/Merge/front/src/router/App.js
index d7f5f09..efc4067 100644
--- a/Merge/front/src/router/App.js
+++ b/Merge/front/src/router/App.js
@@ -12,6 +12,8 @@
import NotebookPage from '../components/NotebookPage'
import PlaceholderPage from '../components/PlaceholderPage'
import UserProfile from '../components/UserProfile'
+import PostDetailJWLLL from '../components/PostDetailJWLLL'
+import UploadPageJWLLL from '../components/UploadPageJWLLL'
import AdminPage from '../components/Admin'
import SuperAdmin from '../components/SuperAdmin'
@@ -41,13 +43,13 @@
{/* 2.1 任何登录用户都能看自己的主页 */}
<Route element={<RequireOwnProfile />}>
<Route path="/user/:userId" element={<UserProfile />} />
- </Route>
-
- {/* 2.2 普通用户 */}
+ </Route> {/* 2.2 普通用户 */}
<Route element={<RequireRole allowedRoles={['user']} />}>
<Route path="/home" element={<HomeFeed />} />
+ <Route path="/post/:id" element={<PostDetailJWLLL />} />
<Route path="/posts/new" element={<CreatePost />} />
<Route path="/posts/edit/:postId" element={<CreatePost />} />
+ <Route path="/upload-jwlll" element={<UploadPageJWLLL />} />
<Route path="/notebooks" element={<NotebookPage />} />
<Route path="/dashboard/*" element={<PlaceholderPage />} />
<Route path="/activity" element={<PlaceholderPage pageId="activity" />} />
diff --git a/Merge/front/src/style/HomeFeed.css b/Merge/front/src/style/HomeFeed.css
index f1bf75d..394c84b 100644
--- a/Merge/front/src/style/HomeFeed.css
+++ b/Merge/front/src/style/HomeFeed.css
@@ -113,4 +113,95 @@
.card-likes .likes-count {
font-size: 13px;
color: #666;
+}
+
+/* --------- JWLLL 搜索推荐功能样式 --------- */
+
+/* 搜索框美化 */
+.feed-search {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.search-input {
+ flex: 1;
+ padding: 8px 14px;
+ border: 1.5px solid #e0e0e0;
+ border-radius: 20px;
+ font-size: 15px;
+ outline: none;
+ transition: border 0.2s;
+ background: #fafbfc;
+}
+
+.search-input:focus {
+ border: 1.5px solid #e84c4a;
+ background: #fff;
+}
+
+.search-btn {
+ padding: 8px 22px;
+ border: none;
+ border-radius: 20px;
+ background: linear-gradient(90deg,#ff6a6a,#ff4757);
+ color: #fff;
+ font-weight: 600;
+ font-size: 15px;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(255,71,87,0.08);
+ transition: background 0.2s, box-shadow 0.2s;
+}
+
+.search-btn:hover {
+ background: linear-gradient(90deg,#ff4757,#e84c4a);
+ box-shadow: 0 4px 16px rgba(255,71,87,0.15);
+}
+
+/* 推荐模式切换按钮 */
+.rec-btn {
+ background: #f8f9fa;
+ border: 1px solid #dee2e6;
+ color: #495057;
+ padding: 6px 12px;
+ border-radius: 20px;
+ cursor: pointer;
+ transition: all 0.2s;
+ font-size: 14px;
+ outline: none;
+}
+
+.rec-btn:hover {
+ background: #e9ecef;
+}
+
+.rec-btn.active {
+ background: #fff0f0;
+ border: 2px solid #e84c4a;
+ color: #e84c4a;
+ font-weight: 600;
+}
+
+/* 卡片内容显示区域 */
+.card-content {
+ padding: 0 12px 8px;
+ font-size: 13px;
+ color: #666;
+ line-height: 1.4;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+/* 点击效果 */
+.feed-card {
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s;
+}
+
+.feed-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
\ No newline at end of file
diff --git a/Merge/front/src/style/PostDetail.css b/Merge/front/src/style/PostDetail.css
new file mode 100644
index 0000000..1be7126
--- /dev/null
+++ b/Merge/front/src/style/PostDetail.css
@@ -0,0 +1,436 @@
+/* 帖子详情页面容器 */
+.post-detail {
+ max-width: 800px;
+ margin: 0 auto;
+ background: #fff;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+/* 加载状态 */
+.loading-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ color: #666;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #ff4757;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 20px;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* 错误状态 */
+.error-container {
+ text-align: center;
+ padding: 60px 20px;
+ color: #666;
+}
+
+.error-container h2 {
+ margin-bottom: 16px;
+ color: #333;
+}
+
+/* 顶部导航栏 */
+.post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 16px 20px;
+ border-bottom: 1px solid #eee;
+ background: #fff;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.back-btn {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ border: none;
+ background: #f8f9fa;
+ border-radius: 20px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-size: 14px;
+ color: #333;
+}
+
+.back-btn:hover {
+ background: #e9ecef;
+}
+
+.header-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.action-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border: none;
+ background: #f8f9fa;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ color: #666;
+}
+
+.action-btn:hover {
+ background: #e9ecef;
+}
+
+.action-btn.active {
+ background: #ff4757;
+ color: white;
+}
+
+/* 主要内容区 */
+.post-content {
+ flex: 1;
+ padding: 20px;
+}
+
+.post-title {
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1.4;
+ margin-bottom: 20px;
+ color: #333;
+}
+
+/* 帖子元信息 */
+.post-meta {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.author-info {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: #ff4757;
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+.author-details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.author-name {
+ font-weight: 600;
+ color: #333;
+ font-size: 14px;
+}
+
+.post-date {
+ font-size: 12px;
+ color: #666;
+}
+
+.post-stats {
+ display: flex;
+ gap: 16px;
+}
+
+.stat-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 14px;
+ color: #666;
+}
+
+/* 标签 */
+.post-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.tag {
+ padding: 4px 12px;
+ background: #f8f9fa;
+ border-radius: 16px;
+ font-size: 12px;
+ color: #666;
+ border: 1px solid #e9ecef;
+}
+
+/* 帖子正文 */
+.post-body {
+ margin-bottom: 24px;
+ line-height: 1.6;
+ color: #333;
+ font-size: 16px;
+}
+
+.post-body p {
+ margin-bottom: 16px;
+}
+
+/* 类别信息 */
+.post-category {
+ margin-bottom: 20px;
+ padding: 12px;
+ background: #f8f9fa;
+ border-radius: 8px;
+ font-size: 14px;
+}
+
+.category-label {
+ color: #666;
+ font-weight: 500;
+}
+
+.category-name {
+ color: #333;
+ font-weight: 600;
+}
+
+/* 评论区 */
+.comments-section {
+ margin-top: 32px;
+ padding-top: 24px;
+ border-top: 1px solid #eee;
+}
+
+.comments-header {
+ margin-bottom: 20px;
+}
+
+.comments-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 16px;
+ border: 1px solid #e9ecef;
+ background: #f8f9fa;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-size: 14px;
+ color: #333;
+}
+
+.comments-toggle:hover {
+ background: #e9ecef;
+}
+
+.comments-content {
+ margin-top: 16px;
+}
+
+/* 评论表单 */
+.comment-form {
+ margin-bottom: 24px;
+ padding: 16px;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.comment-input {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid #dee2e6;
+ border-radius: 6px;
+ resize: vertical;
+ font-family: inherit;
+ font-size: 14px;
+ margin-bottom: 12px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.comment-input:focus {
+ border-color: #ff4757;
+}
+
+.comment-submit {
+ padding: 8px 16px;
+ background: #ff4757;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.2s;
+}
+
+.comment-submit:hover {
+ background: #e84c4a;
+}
+
+/* 评论列表 */
+.comments-list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.no-comments {
+ text-align: center;
+ color: #666;
+ font-style: italic;
+ padding: 32px 0;
+}
+
+.comment-item {
+ padding: 16px;
+ background: #fff;
+ border: 1px solid #e9ecef;
+ border-radius: 8px;
+}
+
+.comment-author {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.comment-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: #6c757d;
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: 14px;
+}
+
+.comment-name {
+ font-weight: 600;
+ color: #333;
+ font-size: 14px;
+}
+
+.comment-time {
+ font-size: 12px;
+ color: #666;
+ margin-left: auto;
+}
+
+.comment-content {
+ font-size: 14px;
+ line-height: 1.5;
+ color: #333;
+ margin-left: 40px;
+}
+
+/* 底部操作栏 */
+.post-footer {
+ padding: 16px 20px;
+ border-top: 1px solid #eee;
+ background: #fff;
+ position: sticky;
+ bottom: 0;
+}
+
+.action-bar {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.action-button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 12px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ transition: color 0.2s;
+ color: #666;
+ font-size: 12px;
+}
+
+.action-button:hover {
+ color: #333;
+}
+
+.action-button.liked {
+ color: #ff4757;
+}
+
+.action-button.bookmarked {
+ color: #ffa502;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+ .post-detail {
+ margin: 0;
+ }
+
+ .post-content {
+ padding: 16px;
+ }
+
+ .post-title {
+ font-size: 20px;
+ }
+
+ .post-meta {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ }
+
+ .post-stats {
+ align-self: flex-end;
+ }
+
+ .action-bar {
+ max-width: none;
+ }
+
+ .comment-content {
+ margin-left: 0;
+ margin-top: 8px;
+ }
+}