feat: 添加文章和服务详情页组件

- ArticleDetailPage(文章详情、评论、点赞)
- ServiceDetailPage(服务详情、评分、评论、点赞)
- 更新 App.js 路由配置
- 集成 ArticleStore 和 ServiceStore
This commit is contained in:
mashen
2026-04-09 13:48:21 +00:00
parent 7050f15f0a
commit 8d4eda17e0
3 changed files with 403 additions and 1 deletions

View File

@@ -1,12 +1,15 @@
import React from 'react'; import React from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, useParams } from 'react-router-dom';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import styled from 'styled-components'; import styled from 'styled-components';
import { useAuthStore } from './stores/AuthStore'; import { useAuthStore } from './stores/AuthStore';
import { useUserStore } from './stores/UserStore';
import Layout from './components/common/Layout'; import Layout from './components/common/Layout';
import Loading from './components/common/Loading'; import Loading from './components/common/Loading';
import CitiesPage from './components/region/CitiesPage'; import CitiesPage from './components/region/CitiesPage';
import CityDetailPage from './components/region/CityDetailPage'; import CityDetailPage from './components/region/CityDetailPage';
import ArticleDetailPage from './components/article/ArticleDetailPage';
import ServiceDetailPage from './components/service/ServiceDetailPage';
const Container = styled.div` const Container = styled.div`
max-width: 1200px; max-width: 1200px;
@@ -41,6 +44,8 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/cities" element={<CitiesPage />} /> <Route path="/cities" element={<CitiesPage />} />
<Route path="/cities/:regionId" element={<CityDetailPage />} /> <Route path="/cities/:regionId" element={<CityDetailPage />} />
<Route path="/articles/:articleId" element={<ArticleDetailPageWrapper />} />
<Route path="/services/:serviceId" element={<ServiceDetailPageWrapper />} />
<Route path="/user/profile" element={<UserProfilePage />} /> <Route path="/user/profile" element={<UserProfilePage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
@@ -48,6 +53,16 @@ function App() {
); );
} }
const ArticleDetailPageWrapper = observer(() => {
const { articleId } = useParams();
return <ArticleDetailPage articleId={articleId} />;
});
const ServiceDetailPageWrapper = observer(() => {
const { serviceId } = useParams();
return <ServiceDetailPage serviceId={serviceId} />;
});
const HomePage = observer(() => { const HomePage = observer(() => {
return ( return (
<Container> <Container>

View File

@@ -0,0 +1,183 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useArticleStore } from '../../stores/ArticleStore';
import { useInteractionStore } from '../../stores/InteractionStore';
import Card from '../common/Card';
import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
const Content = styled.div`
line-height: 1.8;
color: #333;
h1, h2, h3 {
color: #2c3e50;
margin-top: 30px;
}
p {
margin-bottom: 15px;
}
`;
const Actions = styled.div`
display: flex;
gap: 10px;
margin: 20px 0;
`;
const Button = styled.button`
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
&:hover {
opacity: 0.8;
}
${props => props.primary && `
background: #667eea;
color: white;
`}
${props => props.secondary && `
background: #6c757d;
color: white;
`}
`;
const CommentsSection = styled.div`
margin-top: 40px;
`;
const CommentForm = styled.form`
margin-bottom: 20px;
textarea {
width: 100%;
min-height: 100px;
padding: 10px;
border: 1px solid #dee2e6;
border-radius: 5px;
resize: vertical;
}
`;
const CommentList = styled.div`
display: flex;
flex-direction: column;
gap: 15px;
`;
const CommentItem = styled.div`
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
`;
const ArticleDetailPage = observer(({ articleId }) => {
const articleStore = useArticleStore();
const interactionStore = useInteractionStore();
const navigate = useNavigate();
const [comment, setComment] = useState('');
const [liked, setLiked] = useState(false);
useEffect(() => {
articleStore.fetchArticle(articleId);
articleStore.fetchArticleComments(articleId);
articleStore.fetchArticleStats(articleId);
}, [articleId, articleStore]);
const handleLike = async () => {
const result = await articleStore.likeArticle(articleId);
if (result) {
setLiked(result.liked);
articleStore.fetchArticleStats(articleId);
}
};
const handleComment = async (e) => {
e.preventDefault();
if (!comment.trim()) return;
const result = await interactionStore.createComment('article', articleId, comment);
if (result.success) {
setComment('');
articleStore.fetchArticleComments(articleId);
}
};
if (articleStore.loading) {
return <Loading message="加载文章详情..." />;
}
if (articleStore.error) {
return (
<ErrorMessage
message={articleStore.error}
onDismiss={() => articleStore.error = null}
/>
);
}
if (!articleStore.currentArticle) {
return <ErrorMessage message="文章不存在" />;
}
const article = articleStore.currentArticle;
return (
<div>
<h1>{article.title}</h1>
<p>作者: {article.author_username} | {article.article_type_display}</p>
<Content dangerouslySetInnerHTML={{ __html: article.content }} />
<Actions>
<Button primary onClick={handleLike}>
{liked ? '已点赞' : '点赞'}
</Button>
<Button secondary>
收藏
</Button>
<Button secondary>
分享
</Button>
</Actions>
<CommentsSection>
<h2>评论 ({articleStore.currentArticle.comments_count})</h2>
<CommentForm onSubmit={handleComment}>
<textarea
placeholder="写下你的评论..."
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<Button type="submit" primary style={{ marginTop: '10px' }}>
发表评论
</Button>
</CommentForm>
<CommentList>
{articleStore.currentArticle.comments.map((comment) => (
<CommentItem key={comment.id}>
<p><strong>{comment.author_username}</strong></p>
<p>{comment.content}</p>
<p style={{ color: '#999', fontSize: '14px' }}>
{comment.created_at}
</p>
</CommentItem>
))}
</CommentList>
</CommentsSection>
</div>
);
});
export default ArticleDetailPage;

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useServiceStore } from '../../stores/ServiceStore';
import { useInteractionStore } from '../../stores/InteractionStore';
import Card from '../common/Card';
import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
const ServiceCard = styled(Card)`
cursor: pointer;
`;
const Rating = styled.div`
display: flex;
align-items: center;
gap: 5px;
margin: 10px 0;
span {
font-weight: bold;
}
`;
const Star = styled.span`
color: ${props => props.filled ? '#ffc107' : '#dee2e6'};
font-size: 20px;
`;
const CommentsSection = styled.div`
margin-top: 40px;
`;
const CommentForm = styled.form`
margin-bottom: 20px;
textarea {
width: 100%;
min-height: 100px;
padding: 10px;
border: 1px solid #dee2e6;
border-radius: 5px;
resize: vertical;
}
`;
const ServiceDetailPage = observer(({ serviceId }) => {
const serviceStore = useServiceStore();
const interactionStore = useInteractionStore();
const navigate = useNavigate();
const [comment, setComment] = useState('');
const [liked, setLiked] = useState(false);
useEffect(() => {
serviceStore.fetchService(serviceId);
serviceStore.fetchServiceComments(serviceId);
serviceStore.fetchServiceStats(serviceId);
}, [serviceId, serviceStore]);
const handleLike = async () => {
const result = await serviceStore.likeService(serviceId);
if (result) {
setLiked(result.liked);
serviceStore.fetchServiceStats(serviceId);
}
};
const handleRate = async (score) => {
const result = await serviceStore.rateService(serviceId, score);
if (result.success) {
serviceStore.fetchServiceStats(serviceId);
}
};
const handleComment = async (e) => {
e.preventDefault();
if (!comment.trim()) return;
const result = await interactionStore.createComment('service', serviceId, comment);
if (result.success) {
setComment('');
serviceStore.fetchServiceComments(serviceId);
}
};
if (serviceStore.loading) {
return <Loading message="加载服务详情..." />;
}
if (serviceStore.error) {
return (
<ErrorMessage
message={serviceStore.error}
onDismiss={() => serviceStore.error = null}
/>
);
}
if (!serviceStore.currentService) {
return <ErrorMessage message="服务不存在" />;
}
const service = serviceStore.currentService;
const stats = serviceStore.currentService.stats || {};
return (
<div>
{service.image && (
<img
src={service.image}
alt={service.name}
style={{
width: '100%',
maxHeight: '400px',
objectFit: 'cover',
borderRadius: '8px',
marginBottom: '20px',
}}
/>
)}
<h1>{service.name}</h1>
<p>{service.category_display}</p>
<p>{service.description}</p>
{service.address && (
<p><strong>地址:</strong> {service.address}</p>
)}
{service.contact && (
<p><strong>联系方式:</strong> {service.contact}</p>
)}
<Rating>
<span>评分:</span>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
filled={star <= Math.round(stats.avg_rating || 0)}
onClick={() => handleRate(star)}
style={{ cursor: 'pointer' }}
>
</Star>
))}
<span>
{stats.avg_rating || 0} ({stats.ratings_count || 0} 评分)
</span>
</Rating>
<p>
<strong>点赞:</strong> {stats.likes_count || 0} |
<strong>评论:</strong> {stats.comments_count || 0}
</p>
<CommentsSection>
<h2>评论 ({stats.comments_count || 0})</h2>
<CommentForm onSubmit={handleComment}>
<textarea
placeholder="写下你的评论..."
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
<button
type="submit"
style={{
padding: '10px 20px',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
marginTop: '10px',
}}
>
发表评论
</button>
</CommentForm>
{service.currentService.comments && service.currentService.comments.map((comment) => (
<div
key={comment.id}
style={{
background: '#f8f9fa',
padding: '15px',
borderRadius: '5px',
marginBottom: '15px',
}}
>
<p><strong>{comment.author_username}</strong></p>
<p>{comment.content}</p>
<p style={{ color: '#999', fontSize: '14px' }}>
{comment.created_at}
</p>
</div>
))}
</CommentsSection>
</div>
);
});
export default ServiceDetailPage;