From 73c874cc98e460361c03eb453e3568ca42584588 Mon Sep 17 00:00:00 2001 From: maoshen Date: Sun, 12 Apr 2026 21:50:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E5=9F=8E=E5=B8=82?= =?UTF-8?q?=E6=89=8B=E5=86=8C=E8=AF=A6=E6=83=85=E9=A1=B5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: - 新增文章详情页 (ArticleDetail.jsx) - 文章内容展示 - 点赞、收藏、分享功能 - 评分系统 - 评论功能 - 相关文章推荐 - 新增服务详情页 (ServiceDetail.jsx) - 服务信息展示 - 图片画廊 - 营业时间、价格范围 - 用户评价和评分分布 - 相关服务推荐 - 地图位置 - 更新路由配置 - 扩展 API 接口(点赞、收藏) 样式: - ArticleDetail.css - 文章详情页样式 - ServiceDetail.css - 服务详情页样式 --- city-manual/frontend/src/App.jsx | 11 +- city-manual/frontend/src/api.js | 12 +- .../frontend/src/pages/ArticleDetail.css | 327 ++++++++++ .../frontend/src/pages/ArticleDetail.jsx | 292 +++++++++ .../frontend/src/pages/ServiceDetail.css | 560 ++++++++++++++++++ .../frontend/src/pages/ServiceDetail.jsx | 385 ++++++++++++ 6 files changed, 1577 insertions(+), 10 deletions(-) create mode 100644 city-manual/frontend/src/pages/ArticleDetail.css create mode 100644 city-manual/frontend/src/pages/ArticleDetail.jsx create mode 100644 city-manual/frontend/src/pages/ServiceDetail.css create mode 100644 city-manual/frontend/src/pages/ServiceDetail.jsx diff --git a/city-manual/frontend/src/App.jsx b/city-manual/frontend/src/App.jsx index 5070d56..d97f94f 100644 --- a/city-manual/frontend/src/App.jsx +++ b/city-manual/frontend/src/App.jsx @@ -3,6 +3,8 @@ import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react import Home from './pages/Home'; import Cities from './pages/Cities'; import RegionDetail from './pages/RegionDetail'; +import ArticleDetail from './pages/ArticleDetail'; +import ServiceDetail from './pages/ServiceDetail'; import Login from './pages/Login'; import Register from './pages/Register'; import api from './api'; @@ -90,13 +92,4 @@ function App() { ); } -// 占位组件 - 后续完善 -function ArticleDetail() { - return

文章详情页

开发中...

; -} - -function ServiceDetail() { - return

服务详情页

开发中...

; -} - export default App; diff --git a/city-manual/frontend/src/api.js b/city-manual/frontend/src/api.js index 2ecae72..0843cfe 100644 --- a/city-manual/frontend/src/api.js +++ b/city-manual/frontend/src/api.js @@ -56,7 +56,7 @@ export const usersApi = { }; export const commentsApi = { - getList: () => api.get('/comments/'), + getList: (params) => api.get('/comments/', { params }), create: (data) => api.post('/comments/', data), }; @@ -65,4 +65,14 @@ export const ratingsApi = { create: (data) => api.post('/ratings/', data), }; +export const likesApi = { + create: (data) => api.post('/likes/', data), + delete: (id) => api.delete(`/likes/${id}/`), +}; + +export const favoritesApi = { + create: (data) => api.post('/favorites/', data), + delete: (id) => api.delete(`/favorites/${id}/`), +}; + export default api; diff --git a/city-manual/frontend/src/pages/ArticleDetail.css b/city-manual/frontend/src/pages/ArticleDetail.css new file mode 100644 index 0000000..752a506 --- /dev/null +++ b/city-manual/frontend/src/pages/ArticleDetail.css @@ -0,0 +1,327 @@ +.article-detail { + min-height: 100vh; + background: #f8f9fa; +} + +.article-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 30px 0; +} + +.article-header .breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + margin-bottom: 20px; +} + +.article-header .breadcrumb a { + color: rgba(255, 255, 255, 0.9); + text-decoration: none; +} + +.article-header .breadcrumb a:hover { + text-decoration: underline; +} + +.article-header .breadcrumb .separator { + color: rgba(255, 255, 255, 0.6); +} + +.article-header h1 { + font-size: 32px; + margin-bottom: 15px; + line-height: 1.4; +} + +.article-header .meta { + display: flex; + flex-wrap: wrap; + gap: 20px; + font-size: 14px; + color: rgba(255, 255, 255, 0.9); +} + +.article-header .meta span { + display: flex; + align-items: center; + gap: 5px; +} + +/* Actions Bar */ +.actions-bar { + background: white; + border-bottom: 1px solid #e1e4e8; + padding: 15px 0; + position: sticky; + top: 60px; + z-index: 100; +} + +.actions-bar .container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.actions { + display: flex; + gap: 10px; +} + +.action-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 8px 16px; + border: 1px solid #e1e4e8; + background: white; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.action-btn:hover { + background: #f6f8fa; +} + +.action-btn.active { + color: #667eea; + border-color: #667eea; +} + +.rating { + display: flex; + align-items: center; + gap: 5px; +} + +.rating .star { + background: none; + border: none; + font-size: 24px; + color: #ddd; + cursor: pointer; + transition: color 0.2s; +} + +.rating .star:hover, +.rating .star.active { + color: #ffc107; +} + +.rating-text { + margin-left: 10px; + font-size: 14px; + color: #666; +} + +/* Content Wrapper */ +.content-wrapper { + display: grid; + grid-template-columns: 1fr 300px; + gap: 30px; + padding: 30px 0; +} + +.article-content { + background: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.article-content .content { + line-height: 1.8; + font-size: 16px; + color: #24292e; +} + +.article-content .content h1, +.article-content .content h2, +.article-content .content h3 { + margin-top: 24px; + margin-bottom: 16px; + color: #1a1a1a; +} + +.article-content .content p { + margin-bottom: 16px; +} + +.article-content .tags { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #e1e4e8; +} + +.article-content .tag { + background: #f6f8fa; + color: #0366d6; + padding: 4px 12px; + border-radius: 12px; + font-size: 13px; +} + +/* Sidebar */ +.sidebar { + position: sticky; + top: 120px; + height: fit-content; +} + +.related-articles { + background: white; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.related-articles h3 { + margin-bottom: 15px; + color: #24292e; +} + +.related-articles ul { + list-style: none; + padding: 0; + margin: 0; +} + +.related-articles li { + margin-bottom: 10px; +} + +.related-articles li a { + color: #0366d6; + text-decoration: none; + font-size: 14px; + line-height: 1.5; +} + +.related-articles li a:hover { + text-decoration: underline; +} + +/* Comments Section */ +.comments-section { + background: white; + border-top: 1px solid #e1e4e8; + padding: 30px 0; + margin-top: 30px; +} + +.comments-section h2 { + margin-bottom: 20px; + color: #24292e; +} + +.comment-form { + margin-bottom: 30px; +} + +.comment-form textarea { + width: 100%; + padding: 12px; + border: 1px solid #e1e4e8; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + resize: vertical; + margin-bottom: 10px; +} + +.comment-form .btn-submit { + background: #667eea; + color: white; + border: none; + padding: 10px 24px; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} + +.comment-form .btn-submit:hover { + background: #5568d3; +} + +.comments-list { + display: flex; + flex-direction: column; + gap: 15px; +} + +.comment { + padding: 15px; + border: 1px solid #e1e4e8; + border-radius: 6px; +} + +.comment-header { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + font-size: 14px; +} + +.comment .username { + font-weight: 600; + color: #24292e; +} + +.comment .date { + color: #666; +} + +.comment-content { + line-height: 1.6; + color: #24292e; +} + +.empty { + text-align: center; + padding: 40px; + color: #666; +} + +/* Loading */ +.loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 400px; + font-size: 16px; + color: #666; +} + +/* Responsive */ +@media (max-width: 768px) { + .content-wrapper { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } + + .article-header h1 { + font-size: 24px; + } + + .actions-bar .container { + flex-direction: column; + gap: 15px; + } + + .actions { + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/city-manual/frontend/src/pages/ArticleDetail.jsx b/city-manual/frontend/src/pages/ArticleDetail.jsx new file mode 100644 index 0000000..5c4faba --- /dev/null +++ b/city-manual/frontend/src/pages/ArticleDetail.jsx @@ -0,0 +1,292 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link, useNavigate } from 'react-router-dom'; +import { articlesApi, commentsApi, ratingsApi, likesApi } from '../api'; +import './ArticleDetail.css'; + +function ArticleDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const [article, setArticle] = useState(null); + const [comments, setComments] = useState([]); + const [ratings, setRatings] = useState([]); + const [relatedArticles, setRelatedArticles] = useState([]); + const [loading, setLoading] = useState(true); + const [newComment, setNewComment] = useState(''); + const [rating, setRating] = useState(0); + const [isLiked, setIsLiked] = useState(false); + const [isFavorited, setIsFavorited] = useState(false); + + useEffect(() => { + loadData(); + }, [id]); + + async function loadData() { + setLoading(true); + try { + const [articleRes, commentsRes] = await Promise.all([ + articlesApi.getDetail(id), + commentsApi.getList({ article: id }), + ]); + setArticle(articleRes.data); + setComments(commentsRes.data.results || commentsRes.data); + setIsLiked(articleRes.data.is_liked || false); + setIsFavorited(articleRes.data.is_favorited || false); + + // 加载相关文章 + if (articleRes.data.region) { + const relatedRes = await articlesApi.getList({ + region: articleRes.data.region.id, + limit: 5, + }); + setRelatedArticles( + (relatedRes.data.results || relatedRes.data).filter((a) => a.id !== parseInt(id)) + ); + } + } catch (error) { + console.error('加载数据失败:', error); + if (error.response?.status === 404) { + navigate('/'); + } + } finally { + setLoading(false); + } + } + + const handleAddComment = async (e) => { + e.preventDefault(); + if (!newComment.trim()) return; + + try { + await commentsApi.create({ + content: newComment, + object_type: 'article', + object_id: id, + }); + setNewComment(''); + loadData(); // 重新加载评论 + } catch (error) { + alert('评论失败,请登录后重试'); + } + }; + + const handleRating = async (score) => { + try { + await ratingsApi.create({ + score, + object_type: 'article', + object_id: id, + }); + setRating(score); + loadData(); + } catch (error) { + alert('评分失败'); + } + }; + + const handleLike = async () => { + try { + if (isLiked) { + await likesApi.delete(id); + } else { + await likesApi.create({ + object_type: 'article', + object_id: id, + }); + } + setIsLiked(!isLiked); + loadData(); + } catch (error) { + alert('操作失败'); + } + }; + + const handleFavorite = async () => { + try { + if (isFavorited) { + await favoritesApi.delete(id); + } else { + await favoritesApi.create({ + object_type: 'article', + object_id: id, + }); + } + setIsFavorited(!isFavorited); + } catch (error) { + alert('操作失败'); + } + }; + + if (loading || !article) { + return
加载中...
; + } + + return ( +
+ {/* Header */} +
+
+ +

{article.title}

+
+ ✍️ {article.author?.username || '匿名'} + 📅 {new Date(article.created_at).toLocaleDateString('zh-CN')} + 📑 {getContentTypeName(article.content_type)} + 👁️ {article.view_count || 0} 阅读 +
+
+
+ + {/* Actions */} +
+
+
+ + + + +
+
+ 评分: + {[1, 2, 3, 4, 5].map((star) => ( + + ))} + {article.rating_average > 0 && ( + + {article.rating_average.toFixed(1)} ({article.rating_count}人) + + )} +
+
+
+ + {/* Content */} +
+
+
+ + {/* Tags */} + {article.tags && article.tags.length > 0 && ( +
+ {article.tags.map((tag, index) => ( + #{tag} + ))} +
+ )} +
+ + {/* Sidebar */} + +
+ + {/* Comments */} +
+
+

评论 ({comments.length})

+ + {/* Add Comment */} +
+