feat: 完整集成JWLLL搜索推荐系统到Merge项目
新增功能:
- 完整的JWLLL搜索推荐后端服务 (back_jwlll/)
- 前端智能搜索和推荐功能集成
- HomeFeed组件增强: 数据源切换(原始数据 ↔ 智能推荐)
- 新增PostDetailJWLLL和UploadPageJWLLL组件
- 新增search_jwlll.js API接口
技术特性:
- 标签推荐和协同过滤推荐算法
- 中文分词和Word2Vec语义搜索
- 100%向后兼容,原功能完全保留
- 独立服务架构,无冲突部署
集成内容:
- JWLLL后端服务配置和依赖
- 前端路由和组件更新
- 样式文件和API集成
- 项目文档和启动工具
Change-Id: I1d008cf04eee40e7d81bfb9109f933d3447d1760
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>