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 */}
+
+
+ {/* 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 */}
+
+
+ {/* Comments List */}
+
+ {comments.length > 0 ? (
+ comments.map((comment) => (
+
+
+ {comment.author?.username || '匿名用户'}
+ {new Date(comment.created_at).toLocaleDateString('zh-CN')}
+
+
{comment.content}
+
+ ))
+ ) : (
+
暂无评论,快来抢沙发吧!
+ )}
+
+
+
+
+ );
+}
+
+function getContentTypeName(type) {
+ const map = {
+ city_info: '城市信息',
+ history: '历史',
+ culture: '文化',
+ practical: '实用信息',
+ life: '生活指南',
+ };
+ return map[type] || type;
+}
+
+function formatContent(content) {
+ // 简单的 markdown 转 HTML
+ return content
+ .replace(/^### (.*$)/gim, '$1
')
+ .replace(/^## (.*$)/gim, '$1
')
+ .replace(/^# (.*$)/gim, '$1
')
+ .replace(/\*\*(.*)\*\*/gim, '$1')
+ .replace(/\*(.*)\*/gim, '$1')
+ .replace(/\n/gim, '
');
+}
+
+function shareArticle() {
+ const url = window.location.href;
+ const title = document.title;
+
+ if (navigator.share) {
+ navigator.share({ title, url });
+ } else {
+ navigator.clipboard.writeText(url);
+ alert('链接已复制到剪贴板');
+ }
+}
+
+export default ArticleDetail;
diff --git a/city-manual/frontend/src/pages/ServiceDetail.css b/city-manual/frontend/src/pages/ServiceDetail.css
new file mode 100644
index 0000000..d82701a
--- /dev/null
+++ b/city-manual/frontend/src/pages/ServiceDetail.css
@@ -0,0 +1,560 @@
+.service-detail {
+ min-height: 100vh;
+ background: #f8f9fa;
+}
+
+.service-header {
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ color: white;
+ padding: 30px 0;
+}
+
+.service-header .breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 14px;
+ margin-bottom: 20px;
+}
+
+.service-header .breadcrumb a {
+ color: rgba(255, 255, 255, 0.9);
+ text-decoration: none;
+}
+
+.service-header .breadcrumb a:hover {
+ text-decoration: underline;
+}
+
+.service-header .breadcrumb .separator {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.header-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 30px;
+}
+
+.header-content .info {
+ flex: 1;
+}
+
+.header-content h1 {
+ font-size: 36px;
+ margin-bottom: 15px;
+}
+
+.category-badge {
+ display: inline-block;
+ background: rgba(255, 255, 255, 0.2);
+ padding: 6px 16px;
+ border-radius: 20px;
+ font-size: 14px;
+ margin-bottom: 15px;
+}
+
+.rating-summary {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 15px;
+}
+
+.rating-summary .stars {
+ display: flex;
+ gap: 2px;
+}
+
+.rating-summary .star {
+ font-size: 20px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.rating-summary .star.active {
+ color: #ffc107;
+}
+
+.rating-summary .rating-score {
+ font-size: 24px;
+ font-weight: bold;
+}
+
+.rating-summary .rating-count {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 14px;
+}
+
+.meta {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 14px;
+}
+
+.meta span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.main-image {
+ width: 300px;
+ height: 200px;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.main-image img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* 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: 10px 20px;
+ 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.primary {
+ background: #f5576c;
+ color: white;
+ border-color: #f5576c;
+}
+
+.action-btn.primary:hover {
+ background: #e04a5e;
+}
+
+.user-rating {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.user-rating .star {
+ background: none;
+ border: none;
+ font-size: 24px;
+ color: #ddd;
+ cursor: pointer;
+ transition: color 0.2s;
+}
+
+.user-rating .star:hover,
+.user-rating .star.active {
+ color: #ffc107;
+}
+
+/* Tabs */
+.tabs-container {
+ background: white;
+ border-bottom: 1px solid #e1e4e8;
+}
+
+.tabs-container .container {
+ display: flex;
+ justify-content: center;
+}
+
+.tabs {
+ display: flex;
+ gap: 0;
+}
+
+.tab {
+ padding: 15px 30px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 15px;
+ color: #666;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+}
+
+.tab:hover {
+ color: #f5576c;
+}
+
+.tab.active {
+ color: #f5576c;
+ border-bottom-color: #f5576c;
+}
+
+/* Content Wrapper */
+.content-wrapper {
+ display: grid;
+ grid-template-columns: 1fr 320px;
+ gap: 30px;
+ padding: 30px 0;
+}
+
+.main-content {
+ background: white;
+ border-radius: 8px;
+ padding: 30px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.service-info .section {
+ margin-bottom: 30px;
+ padding-bottom: 30px;
+ border-bottom: 1px solid #e1e4e8;
+}
+
+.service-info .section:last-child {
+ border-bottom: none;
+}
+
+.service-info h2 {
+ font-size: 20px;
+ margin-bottom: 15px;
+ color: #24292e;
+}
+
+.description {
+ line-height: 1.8;
+ color: #24292e;
+}
+
+.features-list {
+ list-style: none;
+ padding: 0;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 10px;
+}
+
+.features-list li {
+ color: #24292e;
+ font-size: 15px;
+}
+
+.hours, .price {
+ color: #24292e;
+ font-size: 15px;
+ line-height: 1.6;
+}
+
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 15px;
+}
+
+.image-grid img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: transform 0.2s;
+}
+
+.image-grid img:hover {
+ transform: scale(1.05);
+}
+
+/* Comments Section */
+.comments-section h2 {
+ margin-bottom: 20px;
+ color: #24292e;
+}
+
+.rating-distribution {
+ margin-bottom: 30px;
+ padding: 20px;
+ background: #f8f9fa;
+ border-radius: 6px;
+}
+
+.rating-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.rating-bar .score {
+ width: 40px;
+ font-size: 14px;
+ color: #666;
+}
+
+.rating-bar .bar {
+ flex: 1;
+ height: 8px;
+ background: #e1e4e8;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.rating-bar .fill {
+ height: 100%;
+ background: linear-gradient(90deg, #f093fb, #f5576c);
+ border-radius: 4px;
+}
+
+.rating-bar .count {
+ width: 30px;
+ text-align: right;
+ font-size: 13px;
+ color: #666;
+}
+
+.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: #f5576c;
+ 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: #e04a5e;
+}
+
+.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;
+ align-items: flex-start;
+ margin-bottom: 10px;
+}
+
+.user-info {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.comment .username {
+ font-weight: 600;
+ color: #24292e;
+}
+
+.comment .rating {
+ display: flex;
+ gap: 2px;
+}
+
+.comment .rating .star {
+ font-size: 14px;
+ color: #ddd;
+}
+
+.comment .rating .star.active {
+ color: #ffc107;
+}
+
+.comment .date {
+ color: #666;
+ font-size: 13px;
+}
+
+.comment-content {
+ line-height: 1.6;
+ color: #24292e;
+}
+
+.empty {
+ text-align: center;
+ padding: 40px;
+ color: #666;
+}
+
+/* Sidebar */
+.sidebar {
+ position: sticky;
+ top: 120px;
+ height: fit-content;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.related-services,
+.map-section {
+ background: white;
+ border-radius: 8px;
+ padding: 20px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.related-services h3,
+.map-section h3 {
+ margin-bottom: 15px;
+ color: #24292e;
+}
+
+.service-list {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.service-card {
+ display: flex;
+ gap: 12px;
+ text-decoration: none;
+ transition: transform 0.2s;
+}
+
+.service-card:hover {
+ transform: translateX(5px);
+}
+
+.service-card img {
+ width: 80px;
+ height: 60px;
+ object-fit: cover;
+ border-radius: 4px;
+}
+
+.service-card .info {
+ flex: 1;
+}
+
+.service-card h4 {
+ font-size: 14px;
+ color: #24292e;
+ margin-bottom: 5px;
+}
+
+.service-card .rating {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 13px;
+}
+
+.service-card .star {
+ color: #ffc107;
+}
+
+.map {
+ width: 100%;
+ height: 200px;
+ background: #e1e4e8;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.map img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* 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;
+ }
+
+ .header-content {
+ flex-direction: column;
+ }
+
+ .main-image {
+ width: 100%;
+ height: 200px;
+ }
+
+ .service-header h1 {
+ font-size: 24px;
+ }
+
+ .actions-bar .container {
+ flex-direction: column;
+ gap: 15px;
+ }
+
+ .actions {
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+
+ .tabs {
+ overflow-x: auto;
+ }
+}
diff --git a/city-manual/frontend/src/pages/ServiceDetail.jsx b/city-manual/frontend/src/pages/ServiceDetail.jsx
new file mode 100644
index 0000000..8481760
--- /dev/null
+++ b/city-manual/frontend/src/pages/ServiceDetail.jsx
@@ -0,0 +1,385 @@
+import { useState, useEffect } from 'react';
+import { useParams, Link, useNavigate } from 'react-router-dom';
+import { servicesApi, commentsApi, ratingsApi } from '../api';
+import './ServiceDetail.css';
+
+function ServiceDetail() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const [service, setService] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [relatedServices, setRelatedServices] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [newComment, setNewComment] = useState('');
+ const [rating, setRating] = useState(0);
+ const [activeTab, setActiveTab] = useState('info');
+
+ useEffect(() => {
+ loadData();
+ }, [id]);
+
+ async function loadData() {
+ setLoading(true);
+ try {
+ const [serviceRes, commentsRes] = await Promise.all([
+ servicesApi.getDetail(id),
+ commentsApi.getList({ service: id }),
+ ]);
+ setService(serviceRes.data);
+ setComments(commentsRes.data.results || commentsRes.data);
+ setRating(serviceRes.data.user_rating || 0);
+
+ // 加载相关服务
+ if (serviceRes.data.region) {
+ const relatedRes = await servicesApi.getList({
+ region: serviceRes.data.region.id,
+ category: serviceRes.data.category,
+ limit: 6,
+ });
+ setRelatedServices(
+ (relatedRes.data.results || relatedRes.data).filter((s) => s.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: 'service',
+ object_id: id,
+ });
+ setNewComment('');
+ loadData();
+ } catch (error) {
+ alert('评论失败,请登录后重试');
+ }
+ };
+
+ const handleRating = async (score) => {
+ try {
+ await ratingsApi.create({
+ score,
+ object_type: 'service',
+ object_id: id,
+ });
+ setRating(score);
+ loadData();
+ } catch (error) {
+ alert('评分失败');
+ }
+ };
+
+ if (loading || !service) {
+ return 加载中...
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
{service.name}
+
{getCategoryName(service.category)}
+
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ★
+
+ ))}
+
+
{service.rating_average?.toFixed(1) || '暂无'}
+
({service.rating_count || 0}条评价)
+
+
+ {service.address && (
+ 📍 {service.address}
+ )}
+ {service.contact && (
+ 📞 {service.contact}
+ )}
+
+
+ {service.images && service.images.length > 0 && (
+
+

+
+ )}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+ {service.website && (
+
+ )}
+
+
+
+
+ 你的评分:
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+
+
+
+ {/* Tabs */}
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+ {activeTab === 'info' && (
+
+
+
+ {service.features && service.features.length > 0 && (
+
+ 特色
+
+ {service.features.map((feature, index) => (
+ - ✓ {feature}
+ ))}
+
+
+ )}
+
+ {service.opening_hours && (
+
+ 营业时间
+ {service.opening_hours}
+
+ )}
+
+ {service.price_range && (
+
+ 价格范围
+ {service.price_range}
+
+ )}
+
+ {/* Images */}
+ {service.images && service.images.length > 1 && (
+
+ 图片
+
+ {service.images.slice(1).map((img, index) => (
+

+ ))}
+
+
+ )}
+
+ )}
+
+ {activeTab === 'comments' && (
+
+
用户评价
+
+ {/* Rating Distribution */}
+
+ {[5, 4, 3, 2, 1].map((score) => {
+ const count = comments.filter(c => c.rating === score).length;
+ const percentage = comments.length > 0 ? (count / comments.length) * 100 : 0;
+ return (
+
+ );
+ })}
+
+
+ {/* Add Comment */}
+
+
+ {/* Comments List */}
+
+ {comments.length > 0 ? (
+ comments.map((comment) => (
+
+
+
+
{comment.author?.username || '匿名用户'}
+ {comment.rating && (
+
+ {[1, 2, 3, 4, 5].map((star) => (
+ ★
+ ))}
+
+ )}
+
+
{new Date(comment.created_at).toLocaleDateString('zh-CN')}
+
+
{comment.content}
+
+ ))
+ ) : (
+
暂无评价,快来分享你的体验吧!
+ )}
+
+
+ )}
+
+
+ {/* Sidebar */}
+
+
+
+ );
+}
+
+function getCategoryName(category) {
+ const map = {
+ clothing: '衣',
+ food: '食',
+ housing: '住',
+ transportation: '行',
+ entertainment: '娱乐',
+ tourism: '旅游',
+ culture: '文化',
+ };
+ return map[category] || category;
+}
+
+function formatContent(content) {
+ return content
+ .replace(/^### (.*$)/gim, '$1
')
+ .replace(/^## (.*$)/gim, '$1
')
+ .replace(/^# (.*$)/gim, '$1
')
+ .replace(/\*\*(.*)\*\*/gim, '$1')
+ .replace(/\*(.*)\*/gim, '$1')
+ .replace(/\n/gim, '
');
+}
+
+function copyAddress() {
+ const address = document.querySelector('.address')?.textContent.replace('📍 ', '');
+ if (address) {
+ navigator.clipboard.writeText(address);
+ alert('地址已复制');
+ }
+}
+
+function shareService() {
+ const url = window.location.href;
+ const title = document.title;
+
+ if (navigator.share) {
+ navigator.share({ title, url });
+ } else {
+ navigator.clipboard.writeText(url);
+ alert('链接已复制到剪贴板');
+ }
+}
+
+export default ServiceDetail;