feat: 完成城市手册详情页功能

前端:
- 新增文章详情页 (ArticleDetail.jsx)
  - 文章内容展示
  - 点赞、收藏、分享功能
  - 评分系统
  - 评论功能
  - 相关文章推荐
- 新增服务详情页 (ServiceDetail.jsx)
  - 服务信息展示
  - 图片画廊
  - 营业时间、价格范围
  - 用户评价和评分分布
  - 相关服务推荐
  - 地图位置
- 更新路由配置
- 扩展 API 接口(点赞、收藏)

样式:
- ArticleDetail.css - 文章详情页样式
- ServiceDetail.css - 服务详情页样式
This commit is contained in:
maoshen
2026-04-12 21:50:22 +00:00
parent bc3a070952
commit 73c874cc98
6 changed files with 1577 additions and 10 deletions

View File

@@ -3,6 +3,8 @@ import { BrowserRouter as Router, Routes, Route, Link, useNavigate } from 'react
import Home from './pages/Home'; import Home from './pages/Home';
import Cities from './pages/Cities'; import Cities from './pages/Cities';
import RegionDetail from './pages/RegionDetail'; import RegionDetail from './pages/RegionDetail';
import ArticleDetail from './pages/ArticleDetail';
import ServiceDetail from './pages/ServiceDetail';
import Login from './pages/Login'; import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import api from './api'; import api from './api';
@@ -90,13 +92,4 @@ function App() {
); );
} }
// 占位组件 - 后续完善
function ArticleDetail() {
return <div className="container" style={{ padding: '40px 20px' }}><h1>文章详情页</h1><p>开发中...</p></div>;
}
function ServiceDetail() {
return <div className="container" style={{ padding: '40px 20px' }}><h1>服务详情页</h1><p>开发中...</p></div>;
}
export default App; export default App;

View File

@@ -56,7 +56,7 @@ export const usersApi = {
}; };
export const commentsApi = { export const commentsApi = {
getList: () => api.get('/comments/'), getList: (params) => api.get('/comments/', { params }),
create: (data) => api.post('/comments/', data), create: (data) => api.post('/comments/', data),
}; };
@@ -65,4 +65,14 @@ export const ratingsApi = {
create: (data) => api.post('/ratings/', data), 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; export default api;

View File

@@ -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;
}
}

View File

@@ -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 <div className="loading">加载中...</div>;
}
return (
<div className="article-detail">
{/* Header */}
<header className="article-header">
<div className="container">
<nav className="breadcrumb">
<Link to="/">首页</Link>
<span className="separator">/</span>
<Link to="/cities">城市</Link>
<span className="separator">/</span>
{article.region && (
<>
<Link to={`/region/${article.region.id}`}>{article.region.name}</Link>
<span className="separator">/</span>
</>
)}
<span>{article.title}</span>
</nav>
<h1>{article.title}</h1>
<div className="meta">
<span className="author"> {article.author?.username || '匿名'}</span>
<span className="date">📅 {new Date(article.created_at).toLocaleDateString('zh-CN')}</span>
<span className="type">📑 {getContentTypeName(article.content_type)}</span>
<span className="views">👁 {article.view_count || 0} 阅读</span>
</div>
</div>
</header>
{/* Actions */}
<div className="actions-bar">
<div className="container">
<div className="actions">
<button className={`action-btn ${isLiked ? 'active' : ''}`} onClick={handleLike}>
{isLiked ? '❤️' : '🤍'} 点赞 ({article.like_count || 0})
</button>
<button className={`action-btn ${isFavorited ? 'active' : ''}`} onClick={handleFavorite}>
{isFavorited ? '⭐' : '☆'} 收藏
</button>
<button className="action-btn" onClick={() => window.print()}>
🖨 打印
</button>
<button className="action-btn" onClick={() => shareArticle()}>
🔗 分享
</button>
</div>
<div className="rating">
<span>评分</span>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
className={`star ${rating >= star ? 'active' : ''}`}
onClick={() => handleRating(star)}
>
</button>
))}
{article.rating_average > 0 && (
<span className="rating-text">
{article.rating_average.toFixed(1)} ({article.rating_count})
</span>
)}
</div>
</div>
</div>
{/* Content */}
<div className="container content-wrapper">
<main className="article-content">
<div className="content" dangerouslySetInnerHTML={{ __html: formatContent(article.content) }} />
{/* Tags */}
{article.tags && article.tags.length > 0 && (
<div className="tags">
{article.tags.map((tag, index) => (
<span key={index} className="tag">#{tag}</span>
))}
</div>
)}
</main>
{/* Sidebar */}
<aside className="sidebar">
{relatedArticles.length > 0 && (
<div className="related-articles">
<h3>相关文章</h3>
<ul>
{relatedArticles.map((a) => (
<li key={a.id}>
<Link to={`/article/${a.id}`}>{a.title}</Link>
</li>
))}
</ul>
</div>
)}
</aside>
</div>
{/* Comments */}
<div className="comments-section">
<div className="container">
<h2>评论 ({comments.length})</h2>
{/* Add Comment */}
<form className="comment-form" onSubmit={handleAddComment}>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="写下你的评论..."
rows="4"
/>
<button type="submit" className="btn-submit">发表评论</button>
</form>
{/* Comments List */}
<div className="comments-list">
{comments.length > 0 ? (
comments.map((comment) => (
<div key={comment.id} className="comment">
<div className="comment-header">
<span className="username">{comment.author?.username || '匿名用户'}</span>
<span className="date">{new Date(comment.created_at).toLocaleDateString('zh-CN')}</span>
</div>
<div className="comment-content">{comment.content}</div>
</div>
))
) : (
<div className="empty">暂无评论快来抢沙发吧</div>
)}
</div>
</div>
</div>
</div>
);
}
function getContentTypeName(type) {
const map = {
city_info: '城市信息',
history: '历史',
culture: '文化',
practical: '实用信息',
life: '生活指南',
};
return map[type] || type;
}
function formatContent(content) {
// 简单的 markdown 转 HTML
return content
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/\n/gim, '<br>');
}
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;

View File

@@ -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;
}
}

View File

@@ -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 <div className="loading">加载中...</div>;
}
return (
<div className="service-detail">
{/* Header */}
<header className="service-header">
<div className="container">
<nav className="breadcrumb">
<Link to="/">首页</Link>
<span className="separator">/</span>
<Link to="/cities">城市</Link>
<span className="separator">/</span>
{service.region && (
<>
<Link to={`/region/${service.region.id}`}>{service.region.name}</Link>
<span className="separator">/</span>
</>
)}
<span>{service.name}</span>
</nav>
<div className="header-content">
<div className="info">
<h1>{service.name}</h1>
<div className="category-badge">{getCategoryName(service.category)}</div>
<div className="rating-summary">
<div className="stars">
{[1, 2, 3, 4, 5].map((star) => (
<span key={star} className={`star ${star <= Math.round(service.rating_average) ? 'active' : ''}`}>
</span>
))}
</div>
<span className="rating-score">{service.rating_average?.toFixed(1) || '暂无'}</span>
<span className="rating-count">({service.rating_count || 0}条评价)</span>
</div>
<div className="meta">
{service.address && (
<span className="address">📍 {service.address}</span>
)}
{service.contact && (
<span className="contact">📞 {service.contact}</span>
)}
</div>
</div>
{service.images && service.images.length > 0 && (
<div className="main-image">
<img src={service.images[0]} alt={service.name} />
</div>
)}
</div>
</div>
</header>
{/* Actions */}
<div className="actions-bar">
<div className="container">
<div className="actions">
<button className="action-btn primary" onClick={() => window.open(`tel:${service.contact}`)}>
📞 联系电话
</button>
{service.website && (
<button className="action-btn" onClick={() => window.open(service.website)}>
🌐 访问网站
</button>
)}
<button className="action-btn" onClick={() => copyAddress()}>
📋 复制地址
</button>
<button className="action-btn" onClick={() => shareService()}>
🔗 分享
</button>
</div>
<div className="user-rating">
<span>你的评分</span>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
className={`star ${rating >= star ? 'active' : ''}`}
onClick={() => handleRating(star)}
>
</button>
))}
</div>
</div>
</div>
{/* Tabs */}
<div className="tabs-container">
<div className="container">
<div className="tabs">
<button
className={`tab ${activeTab === 'info' ? 'active' : ''}`}
onClick={() => setActiveTab('info')}
>
详情
</button>
<button
className={`tab ${activeTab === 'comments' ? 'active' : ''}`}
onClick={() => setActiveTab('comments')}
>
评论 ({comments.length})
</button>
</div>
</div>
</div>
{/* Content */}
<div className="container content-wrapper">
<main className="main-content">
{activeTab === 'info' && (
<div className="service-info">
<section className="section">
<h2>服务介绍</h2>
<div className="description" dangerouslySetInnerHTML={{ __html: formatContent(service.description) }} />
</section>
{service.features && service.features.length > 0 && (
<section className="section">
<h2>特色</h2>
<ul className="features-list">
{service.features.map((feature, index) => (
<li key={index}> {feature}</li>
))}
</ul>
</section>
)}
{service.opening_hours && (
<section className="section">
<h2>营业时间</h2>
<p className="hours">{service.opening_hours}</p>
</section>
)}
{service.price_range && (
<section className="section">
<h2>价格范围</h2>
<p className="price">{service.price_range}</p>
</section>
)}
{/* Images */}
{service.images && service.images.length > 1 && (
<section className="section">
<h2>图片</h2>
<div className="image-grid">
{service.images.slice(1).map((img, index) => (
<img key={index} src={img} alt={`${service.name} ${index + 1}`} />
))}
</div>
</section>
)}
</div>
)}
{activeTab === 'comments' && (
<div className="comments-section">
<h2>用户评价</h2>
{/* Rating Distribution */}
<div className="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 (
<div key={score} className="rating-bar">
<span className="score">{score}</span>
<div className="bar">
<div className="fill" style={{ width: `${percentage}%` }} />
</div>
<span className="count">{count}</span>
</div>
);
})}
</div>
{/* Add Comment */}
<form className="comment-form" onSubmit={handleAddComment}>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="分享你的体验..."
rows="4"
/>
<button type="submit" className="btn-submit">发表评论</button>
</form>
{/* Comments List */}
<div className="comments-list">
{comments.length > 0 ? (
comments.map((comment) => (
<div key={comment.id} className="comment">
<div className="comment-header">
<div className="user-info">
<span className="username">{comment.author?.username || '匿名用户'}</span>
{comment.rating && (
<div className="rating">
{[1, 2, 3, 4, 5].map((star) => (
<span key={star} className={`star ${star <= comment.rating ? 'active' : ''}`}></span>
))}
</div>
)}
</div>
<span className="date">{new Date(comment.created_at).toLocaleDateString('zh-CN')}</span>
</div>
<div className="comment-content">{comment.content}</div>
</div>
))
) : (
<div className="empty">暂无评价快来分享你的体验吧</div>
)}
</div>
</div>
)}
</main>
{/* Sidebar */}
<aside className="sidebar">
{relatedServices.length > 0 && (
<div className="related-services">
<h3>相关服务</h3>
<div className="service-list">
{relatedServices.map((s) => (
<Link key={s.id} to={`/service/${s.id}`} className="service-card">
{s.images && s.images.length > 0 && (
<img src={s.images[0]} alt={s.name} />
)}
<div className="info">
<h4>{s.name}</h4>
<div className="rating">
<span className="star"></span>
<span>{s.rating_average?.toFixed(1) || '暂无'}</span>
</div>
</div>
</Link>
))}
</div>
</div>
)}
<div className="map-section">
<h3>位置</h3>
{service.location && (
<div className="map">
<img src={`https://maps.qq.com/api/img/locationMarker?marker=${service.location.lat},${service.location.lng}`} alt="map" />
</div>
)}
</div>
</aside>
</div>
</div>
);
}
function getCategoryName(category) {
const map = {
clothing: '衣',
food: '食',
housing: '住',
transportation: '行',
entertainment: '娱乐',
tourism: '旅游',
culture: '文化',
};
return map[category] || category;
}
function formatContent(content) {
return content
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/\n/gim, '<br>');
}
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;