feat: 完成城市手册详情页功能
前端: - 新增文章详情页 (ArticleDetail.jsx) - 文章内容展示 - 点赞、收藏、分享功能 - 评分系统 - 评论功能 - 相关文章推荐 - 新增服务详情页 (ServiceDetail.jsx) - 服务信息展示 - 图片画廊 - 营业时间、价格范围 - 用户评价和评分分布 - 相关服务推荐 - 地图位置 - 更新路由配置 - 扩展 API 接口(点赞、收藏) 样式: - ArticleDetail.css - 文章详情页样式 - ServiceDetail.css - 服务详情页样式
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
327
city-manual/frontend/src/pages/ArticleDetail.css
Normal file
327
city-manual/frontend/src/pages/ArticleDetail.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
292
city-manual/frontend/src/pages/ArticleDetail.jsx
Normal file
292
city-manual/frontend/src/pages/ArticleDetail.jsx
Normal 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;
|
||||||
560
city-manual/frontend/src/pages/ServiceDetail.css
Normal file
560
city-manual/frontend/src/pages/ServiceDetail.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
385
city-manual/frontend/src/pages/ServiceDetail.jsx
Normal file
385
city-manual/frontend/src/pages/ServiceDetail.jsx
Normal 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;
|
||||||
Reference in New Issue
Block a user