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