Initial commit: React + Django 城市手册项目

- Django 4.2 + DRF + JWT + GraphQL
- React 18 + MobX + styled-components
- PostgreSQL 数据库
- Docker + Docker Compose + Nginx
- 完整的功能模块(用户、版块、文章、服务、交互、版主管理)
- 完整的文档(需求、部署、测试)
This commit is contained in:
mashen
2026-04-09 13:56:02 +00:00
commit c866e74ece
98 changed files with 7644 additions and 0 deletions

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,143 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { observer } from 'mobx-react-lite';
import { useAuthStore } from '../../stores/AuthStore';
import { useNavigate } from 'react-router-dom';
const Container = styled.div`
max-width: 400px;
margin: 50px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
`;
const Title = styled.h2`
text-align: center;
margin-bottom: 30px;
color: #333;
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 15px;
`;
const InputGroup = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
`;
const Label = styled.label`
font-weight: 500;
color: #555;
`;
const Input = styled.input`
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
&:focus {
outline: none;
border-color: #667eea;
}
`;
const Button = styled.button`
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #5568d3;
}
&:disabled {
background: #ccc;
cursor: not-allowed;
}
`;
const ErrorMessage = styled.div`
color: #dc3545;
font-size: 14px;
text-align: center;
`;
const Link = styled.a`
text-align: center;
color: #667eea;
text-decoration: none;
font-size: 14px;
&:hover {
text-decoration: underline;
}
`;
const LoginPage = observer(() => {
const authStore = useAuthStore();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
const result = await authStore.login(email, password);
if (result.success) {
navigate('/');
}
};
return (
<Container>
<Title>登录</Title>
<Form onSubmit={handleSubmit}>
{authStore.error && (
<ErrorMessage>{authStore.error}</ErrorMessage>
)}
<InputGroup>
<Label>邮箱</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="请输入邮箱"
required
/>
</InputGroup>
<InputGroup>
<Label>密码</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
/>
</InputGroup>
<Button type="submit" disabled={authStore.loading}>
{authStore.loading ? '登录中...' : '登录'}
</Button>
</Form>
<Link href="/register">没有账号立即注册</Link>
</Container>
);
});
export default LoginPage;

View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import styled from 'styled-components';
import { useNavigate } from 'react-router-dom';
const Container = styled.div`
max-width: 400px;
margin: 50px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
`;
const Title = styled.h2`
text-align: center;
margin-bottom: 30px;
color: #333;
`;
const Form = styled.form`
display: flex;
flex-direction: column;
gap: 15px;
`;
const InputGroup = styled.div`
display: flex;
flex-direction: column;
gap: 5px;
`;
const Label = styled.label`
font-weight: 500;
color: #555;
`;
const Input = styled.input`
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
&:focus {
outline: none;
border-color: #667eea;
}
`;
const Button = styled.button`
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #5568d3;
}
`;
const Link = styled.a`
text-align: center;
color: #667eea;
text-decoration: none;
font-size: 14px;
&:hover {
text-decoration: underline;
}
`;
const RegisterPage = () => {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
}
// TODO: 调用注册 API
console.log('Register:', { username, email, password });
navigate('/login');
};
return (
<Container>
<Title>注册</Title>
<Form onSubmit={handleSubmit}>
{error && (
<div style={{ color: '#dc3545', fontSize: '14px', textAlign: 'center' }}>
{error}
</div>
)}
<InputGroup>
<Label>用户名</Label>
<Input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
required
/>
</InputGroup>
<InputGroup>
<Label>邮箱</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="请输入邮箱"
required
/>
</InputGroup>
<InputGroup>
<Label>密码</Label>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
/>
</InputGroup>
<InputGroup>
<Label>确认密码</Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入密码"
required
/>
</InputGroup>
<Button type="submit">注册</Button>
</Form>
<Link href="/login">已有账号立即登录</Link>
</Container>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import styled from 'styled-components';
const CardWrapper = styled.div`
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
margin-bottom: 20px;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
`;
const Title = styled.h3`
margin: 0 0 10px;
color: #333;
`;
const Description = styled.p`
color: #666;
margin: 0 0 15px;
`;
const Meta = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
color: #999;
`;
const Tags = styled.div`
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 10px;
`;
const Tag = styled.span`
background: #e9ecef;
color: #495057;
padding: 2px 8px;
border-radius: 3px;
font-size: 12px;
`;
function Card({ title, description, meta, tags, children, onClick }) {
return (
<CardWrapper onClick={onClick}>
{title && <Title>{title}</Title>}
{description && <Description>{description}</Description>}
{tags && (
<Tags>
{tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Tags>
)}
{children}
{meta && <Meta>{meta}</Meta>}
</CardWrapper>
);
}
export default Card;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import styled from 'styled-components';
const ErrorWrapper = styled.div`
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
`;
function ErrorMessage({ message, onDismiss }) {
return (
<ErrorWrapper>
{message}
{onDismiss && (
<button
onClick={onDismiss}
style={{
float: 'right',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '16px',
}}
>
×
</button>
)}
</ErrorWrapper>
);
}
export default ErrorMessage;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import styled from 'styled-components';
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
`;
const Header = styled.header`
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 0;
margin-bottom: 30px;
`;
const HeaderContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const Title = styled.h1`
font-size: 28px;
margin: 0;
`;
const Subtitle = styled.p`
margin: 5px 0 0;
opacity: 0.9;
`;
const Nav = styled.nav`
a {
color: white;
text-decoration: none;
margin-left: 20px;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
}
`;
const Footer = styled.footer`
background: #f8f9fa;
padding: 30px 0;
margin-top: 50px;
text-align: center;
color: #6c757d;
`;
function Layout({ children, title, subtitle }) {
return (
<>
<Header>
<Container>
<HeaderContent>
<div>
<Title>{title || '城市手册'}</Title>
{subtitle && <Subtitle>{subtitle}</Subtitle>}
</div>
<Nav>
<a href="/">首页</a>
<a href="/cities">城市</a>
<a href="/services">服务</a>
<a href="/user/profile">个人中心</a>
</Nav>
</HeaderContent>
</Container>
</Header>
<Container>
{children}
</Container>
<Footer>
<Container>
<p>&copy; 2026 城市手册. All rights reserved.</p>
</Container>
</Footer>
</>
);
}
export default Layout;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import styled from 'styled-components';
const LoadingWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
color: #6c757d;
`;
const Spinner = styled.div`
border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-right: 10px;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`;
function Loading({ message = '加载中...' }) {
return (
<LoadingWrapper>
<Spinner />
{message}
</LoadingWrapper>
);
}
export default Loading;

View File

@@ -0,0 +1,65 @@
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useRegionStore } from '../../stores/RegionStore';
import Card from '../common/Card';
import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
const Grid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
padding: 20px 0;
`;
const ProvinceCard = styled(Card)`
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: translateY(-5px);
}
`;
const CitiesPage = observer(() => {
const regionStore = useRegionStore();
const navigate = useNavigate();
useEffect(() => {
regionStore.fetchProvinces();
}, [regionStore]);
const handleProvinceClick = (regionId) => {
navigate(`/cities/${regionId}`);
};
if (regionStore.loading) {
return <Loading message="加载城市列表..." />;
}
if (regionStore.error) {
return (
<ErrorMessage
message={regionStore.error}
onDismiss={() => regionStore.error = null}
/>
);
}
return (
<Grid>
{regionStore.regions.map((province) => (
<ProvinceCard
key={province.id}
title={province.name}
meta={`${province.children_count} 个城市`}
onClick={() => handleProvinceClick(province.id)}
/>
))}
</Grid>
);
});
export default CitiesPage;

View File

@@ -0,0 +1,177 @@
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import { observer } from 'mobx-react-lite';
import { useNavigate } from 'react-router-dom';
import { useRegionStore } from '../../stores/RegionStore';
import { useArticleStore } from '../../stores/ArticleStore';
import { useServiceStore } from '../../stores/ServiceStore';
import Card from '../common/Card';
import Loading from '../common/Loading';
import ErrorMessage from '../common/ErrorMessage';
const InfoGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
margin: 20px 0;
`;
const InfoItem = styled.div`
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
strong {
display: block;
margin-bottom: 5px;
color: #495057;
}
`;
const Tabs = styled.div`
display: flex;
border-bottom: 2px solid #dee2e6;
margin: 30px 0;
`;
const Tab = styled.button`
padding: 10px 20px;
border: none;
background: none;
cursor: pointer;
font-size: 16px;
color: ${props => props.active ? '#667eea' : '#6c757d'};
border-bottom: 2px solid ${props => props.active ? '#667eea' : 'transparent'};
margin-bottom: -2px;
&:hover {
color: #667eea;
}
`;
const ContentGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
`;
const CityDetailPage = observer(() => {
const regionStore = useRegionStore();
const articleStore = useArticleStore();
const serviceStore = useServiceStore();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('articles');
const { regionId } = useParams();
useEffect(() => {
regionStore.fetchRegion(regionId);
regionStore.fetchChildren(regionId);
regionStore.fetchRegionArticles(regionId);
regionStore.fetchRegionServices(regionId);
}, [regionId, regionStore]);
const handleCityClick = (cityId) => {
navigate(`/cities/${cityId}`);
};
if (regionStore.loading) {
return <Loading message="加载城市详情..." />;
}
if (regionStore.error) {
return (
<ErrorMessage
message={regionStore.error}
onDismiss={() => regionStore.error = null}
/>
);
}
if (!regionStore.currentRegion) {
return <ErrorMessage message="城市不存在" />;
}
const region = regionStore.currentRegion;
return (
<div>
<h1>{region.name}</h1>
<p>{region.full_path}</p>
<InfoGrid>
<InfoItem>
<strong>级别</strong>
{region.level_display}
</InfoItem>
<InfoItem>
<strong>子版块数量</strong>
{region.children_count}
</InfoItem>
<InfoItem>
<strong>文章数量</strong>
{region.articles_count}
</InfoItem>
<InfoItem>
<strong>服务数量</strong>
{region.services_count}
</InfoItem>
</InfoGrid>
<h2>下级城市</h2>
<ContentGrid>
{region.children.map((city) => (
<Card
key={city.id}
title={city.name}
meta={city.level_display}
onClick={() => handleCityClick(city.id)}
/>
))}
</ContentGrid>
<Tabs>
<Tab
active={activeTab === 'articles'}
onClick={() => setActiveTab('articles')}
>
文章
</Tab>
<Tab
active={activeTab === 'services'}
onClick={() => setActiveTab('services')}
>
特色服务
</Tab>
</Tabs>
{activeTab === 'articles' && (
<ContentGrid>
{region.articles.map((article) => (
<Card
key={article.id}
title={article.title}
description={article.content.substring(0, 100)}
meta={`作者: ${article.author_username}`}
onClick={() => navigate(`/articles/${article.id}`)}
/>
))}
</ContentGrid>
)}
{activeTab === 'services' && (
<ContentGrid>
{region.services.map((service) => (
<Card
key={service.id}
title={service.name}
description={service.description.substring(0, 100)}
tags={[service.category_display]}
onClick={() => navigate(`/services/${service.id}`)}
/>
))}
</ContentGrid>
)}
</div>
);
});
export default CityDetailPage;

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;