TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 1 | // src/components/HomeFeed.jsx |
| 2 | |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 3 | import React, { useState, useEffect, useCallback } from 'react' |
TRM-coding | 29174c2 | 2025-06-18 23:56:51 +0800 | [diff] [blame] | 4 | import { useNavigate } from 'react-router-dom' |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 5 | import { ThumbsUp } from 'lucide-react' |
| 6 | import { fetchPosts, fetchPost } from '../api/posts_wzy' |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 7 | import { searchAPI } from '../api/search_jwlll' |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 8 | import '../style/HomeFeed.css' |
| 9 | |
| 10 | const categories = [ |
| 11 | '推荐','穿搭','美食','彩妆','影视', |
| 12 | '职场','情感','家居','游戏','旅行','健身' |
| 13 | ] |
| 14 | |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 15 | const recommendModes = [ |
| 16 | { label: '标签推荐', value: 'tag' }, |
| 17 | { label: '协同过滤推荐', value: 'cf' } |
| 18 | ] |
| 19 | |
| 20 | const DEFAULT_USER_ID = '3' // 确保数据库有此用户 |
| 21 | const DEFAULT_TAGS = ['美食','影视','穿搭'] // 可根据实际数据库调整 |
| 22 | |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 23 | export default function HomeFeed() { |
TRM-coding | 29174c2 | 2025-06-18 23:56:51 +0800 | [diff] [blame] | 24 | const navigate = useNavigate() |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 25 | const [activeCat, setActiveCat] = useState('推荐') |
| 26 | const [items, setItems] = useState([]) |
| 27 | const [loading, setLoading] = useState(true) |
| 28 | const [error, setError] = useState(null) |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 29 | // JWLLL 搜索推荐相关状态 |
| 30 | const [search, setSearch] = useState('') |
| 31 | const [recMode, setRecMode] = useState('tag') |
| 32 | const [recCFNum, setRecCFNum] = useState(20) |
| 33 | const [useSearchRecommend, setUseSearchRecommend] = useState(false) // 是否使用搜索推荐模式 // JWLLL 搜索推荐功能函数 |
| 34 | |
| 35 | // JWLLL搜索推荐内容 |
| 36 | const fetchSearchContent = useCallback(async (keyword = '') => { |
| 37 | setLoading(true) |
| 38 | setError(null) |
| 39 | try { |
| 40 | const data = await searchAPI.search(keyword || activeCat, activeCat === '推荐' ? undefined : activeCat) |
| 41 | const formattedItems = (data.results || []).map(item => ({ |
| 42 | id: item.id, |
| 43 | title: item.title, |
| 44 | author: item.author || '佚名', |
| 45 | avatar: `https://i.pravatar.cc/40?img=${item.id}`, |
| 46 | img: item.img || '', |
| 47 | likes: item.heat || 0, |
| 48 | content: item.content |
| 49 | })) |
| 50 | setItems(formattedItems) |
| 51 | } catch (e) { |
| 52 | console.error('搜索失败:', e) |
| 53 | setError('搜索失败') |
| 54 | setItems([]) |
| 55 | } |
| 56 | setLoading(false) |
| 57 | }, [activeCat]) |
| 58 | |
| 59 | // 标签推荐 |
| 60 | const fetchTagRecommend = useCallback(async (tags) => { |
| 61 | setLoading(true) |
| 62 | setError(null) |
| 63 | try { |
| 64 | const data = await searchAPI.recommendByTags(DEFAULT_USER_ID, tags) |
| 65 | const formattedItems = (data.recommendations || []).map(item => ({ |
| 66 | id: item.id, |
| 67 | title: item.title, |
| 68 | author: item.author || '佚名', |
| 69 | avatar: `https://i.pravatar.cc/40?img=${item.id}`, |
| 70 | img: item.img || '', |
| 71 | likes: item.heat || 0, |
| 72 | content: item.content |
| 73 | })) |
| 74 | setItems(formattedItems) |
| 75 | } catch (e) { |
| 76 | console.error('标签推荐失败:', e) |
| 77 | setError('标签推荐失败') |
| 78 | setItems([]) |
| 79 | } |
| 80 | setLoading(false) |
| 81 | }, []) |
| 82 | |
| 83 | // 协同过滤推荐 |
| 84 | const fetchCFRecommend = useCallback(async (topN = recCFNum) => { |
| 85 | setLoading(true) |
| 86 | setError(null) |
| 87 | try { |
| 88 | const data = await searchAPI.userBasedRecommend(DEFAULT_USER_ID, topN) |
| 89 | const formattedItems = (data.recommendations || []).map(item => ({ |
| 90 | id: item.id, |
| 91 | title: item.title, |
| 92 | author: item.author || '佚名', |
| 93 | avatar: `https://i.pravatar.cc/40?img=${item.id}`, |
| 94 | img: item.img || '', |
| 95 | likes: item.heat || 0, |
| 96 | content: item.content |
| 97 | })) |
| 98 | setItems(formattedItems) |
| 99 | } catch (e) { |
| 100 | console.error('协同过滤推荐失败:', e) |
| 101 | setError('协同过滤推荐失败') |
| 102 | setItems([]) |
| 103 | } |
| 104 | setLoading(false) |
| 105 | }, [recCFNum]) |
| 106 | |
| 107 | // 获取用户兴趣标签后再推荐 |
| 108 | const fetchUserTagsAndRecommend = useCallback(async () => { |
| 109 | setLoading(true) |
| 110 | setError(null) |
| 111 | let tags = [] |
| 112 | try { |
| 113 | const data = await searchAPI.getUserTags(DEFAULT_USER_ID) |
| 114 | tags = Array.isArray(data.tags) && data.tags.length > 0 ? data.tags : DEFAULT_TAGS |
| 115 | } catch { |
| 116 | tags = DEFAULT_TAGS |
| 117 | } |
| 118 | if (recMode === 'tag') { |
| 119 | await fetchTagRecommend(tags) |
| 120 | } else { |
| 121 | await fetchCFRecommend() |
| 122 | } |
| 123 | setLoading(false) |
| 124 | }, [recMode, fetchTagRecommend, fetchCFRecommend]) |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 125 | |
| 126 | useEffect(() => { |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 127 | // 原始数据加载函数 |
| 128 | const loadPosts = async () => { |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 129 | try { |
| 130 | const list = await fetchPosts() // [{id, title, heat, created_at}, …] |
| 131 | // 为了拿到 media_urls 和 user_id,这里再拉详情 |
| 132 | const detailed = await Promise.all( |
| 133 | list.map(async p => { |
| 134 | const d = await fetchPost(p.id) |
| 135 | return { |
| 136 | id: d.id, |
| 137 | title: d.title, |
| 138 | author: `作者 ${d.user_id}`, |
| 139 | avatar: `https://i.pravatar.cc/40?img=${d.user_id}`, |
| 140 | img: d.media_urls?.[0] || '', // 用第一张媒体作为封面 |
| 141 | likes: d.heat |
| 142 | } |
| 143 | }) |
| 144 | ) |
| 145 | setItems(detailed) |
| 146 | } catch (e) { |
| 147 | setError(e.message) |
| 148 | } finally { |
| 149 | setLoading(false) |
| 150 | } |
| 151 | } |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 152 | |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 153 | // 根据模式选择加载方式 |
| 154 | if (activeCat === '推荐' && useSearchRecommend) { |
| 155 | fetchUserTagsAndRecommend() |
| 156 | } else { |
| 157 | loadPosts() |
| 158 | } |
| 159 | }, [activeCat, useSearchRecommend, fetchUserTagsAndRecommend]) |
| 160 | // 切换推荐模式时的额外处理 |
| 161 | useEffect(() => { |
| 162 | if (activeCat === '推荐' && useSearchRecommend) { |
| 163 | fetchUserTagsAndRecommend() |
| 164 | } |
| 165 | // eslint-disable-next-line |
| 166 | }, [recMode, fetchUserTagsAndRecommend]) |
| 167 | |
| 168 | // 根据模式选择不同的加载方式 |
| 169 | const handleSearch = e => { |
| 170 | e.preventDefault() |
| 171 | if (useSearchRecommend) { |
| 172 | fetchSearchContent(search) |
| 173 | } else { |
| 174 | // 切换到搜索推荐模式 |
| 175 | setUseSearchRecommend(true) |
| 176 | fetchSearchContent(search) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | const handlePostClick = (postId) => { |
| 181 | navigate(`/post/${postId}`) |
| 182 | } |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 183 | return ( |
| 184 | <div className="home-feed"> |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 185 | {/* 数据源切换 */} |
| 186 | <div style={{marginBottom:12, display:'flex', alignItems:'center', gap:16}}> |
| 187 | <span>数据源:</span> |
| 188 | <div style={{display:'flex', gap:8}}> |
| 189 | <button |
| 190 | className={!useSearchRecommend ? 'rec-btn styled active' : 'rec-btn styled'} |
| 191 | onClick={() => {setUseSearchRecommend(false); setActiveCat('推荐')}} |
| 192 | type="button" |
| 193 | style={{ |
| 194 | borderRadius: 20, |
| 195 | padding: '4px 18px', |
| 196 | border: !useSearchRecommend ? '2px solid #e84c4a' : '1px solid #ccc', |
| 197 | background: !useSearchRecommend ? '#fff0f0' : '#fff', |
| 198 | color: !useSearchRecommend ? '#e84c4a' : '#333', |
| 199 | fontWeight: !useSearchRecommend ? 600 : 400, |
| 200 | cursor: 'pointer', |
| 201 | transition: 'all 0.2s', |
| 202 | outline: 'none', |
| 203 | }} |
| 204 | >原始数据</button> |
| 205 | <button |
| 206 | className={useSearchRecommend ? 'rec-btn styled active' : 'rec-btn styled'} |
| 207 | onClick={() => {setUseSearchRecommend(true); setActiveCat('推荐')}} |
| 208 | type="button" |
| 209 | style={{ |
| 210 | borderRadius: 20, |
| 211 | padding: '4px 18px', |
| 212 | border: useSearchRecommend ? '2px solid #e84c4a' : '1px solid #ccc', |
| 213 | background: useSearchRecommend ? '#fff0f0' : '#fff', |
| 214 | color: useSearchRecommend ? '#e84c4a' : '#333', |
| 215 | fontWeight: useSearchRecommend ? 600 : 400, |
| 216 | cursor: 'pointer', |
| 217 | transition: 'all 0.2s', |
| 218 | outline: 'none', |
| 219 | }} |
| 220 | >智能推荐</button> |
| 221 | </div> |
| 222 | </div> |
| 223 | |
| 224 | {/* 推荐模式切换,仅在推荐页显示且使用搜索推荐时 */} |
| 225 | {activeCat === '推荐' && useSearchRecommend && ( |
| 226 | <div style={{marginBottom:12, display:'flex', alignItems:'center', gap:16}}> |
| 227 | <span style={{marginRight:8}}>推荐模式:</span> |
| 228 | <div style={{display:'flex', gap:8}}> |
| 229 | {recommendModes.map(m => ( |
| 230 | <button |
| 231 | key={m.value} |
| 232 | className={recMode===m.value? 'rec-btn styled active':'rec-btn styled'} |
| 233 | onClick={() => setRecMode(m.value)} |
| 234 | type="button" |
| 235 | style={{ |
| 236 | borderRadius: 20, |
| 237 | padding: '4px 18px', |
| 238 | border: recMode===m.value ? '2px solid #e84c4a' : '1px solid #ccc', |
| 239 | background: recMode===m.value ? '#fff0f0' : '#fff', |
| 240 | color: recMode===m.value ? '#e84c4a' : '#333', |
| 241 | fontWeight: recMode===m.value ? 600 : 400, |
| 242 | cursor: 'pointer', |
| 243 | transition: 'all 0.2s', |
| 244 | outline: 'none', |
| 245 | }} |
| 246 | >{m.label}</button> |
| 247 | ))} |
| 248 | </div> |
| 249 | {/* 协同过滤推荐数量选择 */} |
| 250 | {recMode === 'cf' && ( |
| 251 | <div style={{display:'flex',alignItems:'center',gap:4}}> |
| 252 | <span>推荐数量:</span> |
| 253 | <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'}}> |
| 254 | {[10, 20, 30, 50].map(n => <option key={n} value={n}>{n}</option>)} |
| 255 | </select> |
| 256 | </div> |
| 257 | )} |
| 258 | </div> |
| 259 | )} |
| 260 | |
| 261 | {/* 搜索栏 */} |
| 262 | <form className="feed-search" onSubmit={handleSearch} style={{marginBottom:16, display:'flex', gap:8, alignItems:'center'}}> |
| 263 | <input |
| 264 | type="text" |
| 265 | className="search-input" |
| 266 | placeholder="搜索内容/标题/标签" |
| 267 | value={search} |
| 268 | onChange={e => setSearch(e.target.value)} |
| 269 | /> |
| 270 | <button type="submit" className="search-btn">搜索</button> |
| 271 | </form> |
| 272 | |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 273 | {/* 顶部分类 */} |
| 274 | <nav className="feed-tabs"> |
| 275 | {categories.map(cat => ( |
| 276 | <button |
| 277 | key={cat} |
| 278 | className={cat === activeCat ? 'tab active' : 'tab'} |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 279 | onClick={() => { |
| 280 | setActiveCat(cat); |
| 281 | setSearch(''); |
| 282 | if (useSearchRecommend) { |
| 283 | if (cat === '推荐') { |
| 284 | fetchUserTagsAndRecommend() |
| 285 | } else { |
| 286 | fetchSearchContent() |
| 287 | } |
| 288 | } |
| 289 | }} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 290 | > |
| 291 | {cat} |
| 292 | </button> |
| 293 | ))} |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 294 | </nav> {/* 状态提示 */} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 295 | {loading ? ( |
| 296 | <div className="loading">加载中…</div> |
| 297 | ) : error ? ( |
| 298 | <div className="error">加载失败:{error}</div> |
| 299 | ) : ( |
| 300 | /* 瀑布流卡片区 */ |
| 301 | <div className="feed-grid"> |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 302 | {items.length === 0 ? ( |
| 303 | <div style={{padding:32, color:'#aaa'}}>暂无内容</div> |
| 304 | ) : ( |
| 305 | items.map(item => ( |
| 306 | <div key={item.id} className="feed-card" onClick={() => handlePostClick(item.id)}> |
| 307 | {item.img && <img className="card-img" src={item.img} alt={item.title} />} |
| 308 | <h3 className="card-title">{item.title}</h3> |
| 309 | {item.content && <div className="card-content">{item.content.slice(0, 60) || ''}</div>} |
| 310 | <div className="card-footer"> |
| 311 | <div className="card-author"> |
| 312 | <img className="avatar" src={item.avatar} alt={item.author} /> |
| 313 | <span className="username">{item.author}</span> |
| 314 | </div> |
| 315 | <div className="card-likes"> |
| 316 | <ThumbsUp size={16} /> |
| 317 | <span className="likes-count">{item.likes}</span> |
| 318 | </div> |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 319 | </div> |
| 320 | </div> |
956303669 | 80c1f27 | 2025-06-20 14:08:54 +0800 | [diff] [blame] | 321 | )) |
| 322 | )} |
TRM-coding | d1cbf67 | 2025-06-18 15:15:08 +0800 | [diff] [blame] | 323 | </div> |
| 324 | )} |
| 325 | </div> |
| 326 | ) |
| 327 | } |