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

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
REACT_APP_API_URL=http://localhost:8000
REACT_APP_ENV=development

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:18-alpine as build
# Set work directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy project
COPY . .
# Build
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=build /app/build /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

37
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /graphql {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /media {
proxy_pass http://backend:8000;
}
location /static {
proxy_pass http://backend:8000;
}
gzip on;
gzip_comp_level 5;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
}

48
frontend/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "react-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^1.6.0",
"mobx": "^6.12.0",
"mobx-react-lite": "^4.0.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-scripts": "5.0.1",
"styled-components": "^6.1.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"eslint": "^8.55.0",
"prettier": "^3.1.0"
},
"proxy": "http://localhost:8000"
}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="React + Django App" />
<title>React + Django App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

144
frontend/src/App.js Normal file
View File

@@ -0,0 +1,144 @@
import React from 'react';
import { Routes, Route, useParams } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import styled from 'styled-components';
import { useAuthStore } from './stores/AuthStore';
import { useUserStore } from './stores/UserStore';
import Layout from './components/common/Layout';
import Loading from './components/common/Loading';
import CitiesPage from './components/region/CitiesPage';
import CityDetailPage from './components/region/CityDetailPage';
import ArticleDetailPage from './components/article/ArticleDetailPage';
import ServiceDetailPage from './components/service/ServiceDetailPage';
import LoginPage from './components/auth/LoginPage';
import RegisterPage from './components/auth/RegisterPage';
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 20px;
`;
const Header = styled.header`
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #eee;
`;
const Title = styled.h1`
margin: 0;
font-size: 28px;
`;
function App() {
const authStore = useAuthStore();
// Fetch current user on app load
React.useEffect(() => {
if (authStore.isAuthenticated) {
authStore.fetchCurrentUser();
}
}, [authStore]);
return (
<Layout title="城市手册" subtitle="地方志兼本地生活服务平台">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/cities" element={<CitiesPage />} />
<Route path="/cities/:regionId" element={<CityDetailPage />} />
<Route path="/articles/:articleId" element={<ArticleDetailPageWrapper />} />
<Route path="/services/:serviceId" element={<ServiceDetailPageWrapper />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/user/profile" element={<UserProfilePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout>
);
}
const ArticleDetailPageWrapper = observer(() => {
const { articleId } = useParams();
return <ArticleDetailPage articleId={articleId} />;
});
const ServiceDetailPageWrapper = observer(() => {
const { serviceId } = useParams();
return <ServiceDetailPage serviceId={serviceId} />;
});
const HomePage = observer(() => {
return (
<Container>
<Header>
<Title>欢迎来到城市手册</Title>
<p>探索每个城市的故事与特色</p>
</Header>
<div>
<h2>热门城市</h2>
<p>即将推出...</p>
</div>
<div>
<h2>最新文章</h2>
<p>即将推出...</p>
</div>
</Container>
);
});
const UserProfilePage = observer(() => {
const authStore = useAuthStore();
const userStore = useUserStore();
React.useEffect(() => {
if (authStore.isAuthenticated) {
userStore.fetchCurrentUser();
}
}, [authStore, userStore]);
if (!authStore.isAuthenticated) {
return (
<Container>
<p>请先登录</p>
</Container>
);
}
if (userStore.loading) {
return <Loading message="加载用户信息..." />;
}
return (
<Container>
<Header>
<Title>个人中心</Title>
</Header>
{userStore.user && (
<div>
<h3>用户信息</h3>
<p>用户名: {userStore.user.username}</p>
<p>邮箱: {userStore.user.email}</p>
<p>角色: {userStore.user.role_display}</p>
<h3>统计</h3>
<p>文章数: {userStore.user.articles_count}</p>
<p>服务数: {userStore.user.services_count}</p>
<p>评论数: {userStore.user.comments_count}</p>
<p>点赞数: {userStore.user.likes_count}</p>
<p>收藏数: {userStore.user.favorites_count}</p>
</div>
)}
</Container>
);
});
const NotFoundPage = () => (
<Container>
<Header>
<Title>404</Title>
</Header>
<p>页面未找到</p>
</Container>
);
export default App;

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;

35
frontend/src/index.js Normal file
View File

@@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'mobx-react-lite';
import App from './App';
import './styles/global';
// Import stores
import AuthStore from './stores/AuthStore';
import UserStore from './stores/UserStore';
import RegionStore from './stores/RegionStore';
import ArticleStore from './stores/ArticleStore';
import ServiceStore from './stores/ServiceStore';
import InteractionStore from './stores/InteractionStore';
const root = ReactDOM.createRoot(document.getElementById('root'));
const stores = {
authStore: new AuthStore(),
userStore: new UserStore(),
regionStore: new RegionStore(),
articleStore: new ArticleStore(),
serviceStore: new ServiceStore(),
interactionStore: new InteractionStore(),
};
root.render(
<React.StrictMode>
<Provider {...stores}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>
);

View File

@@ -0,0 +1,54 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000',
headers: {
'Content-Type': 'application/json',
},
});
// Add token to requests
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Handle token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refresh');
const response = await axios.post('/api/token/refresh/', {
refresh: refreshToken,
});
const newToken = response.data.access;
localStorage.setItem('token', newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return api(originalRequest);
} catch (refreshError) {
localStorage.removeItem('token');
localStorage.removeItem('refresh');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
export default api;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,152 @@
import { makeAutoObservable } from 'mobx';
import api from '../services/api';
class ArticleStore {
articles = [];
currentArticle = null;
loading = false;
error = null;
constructor() {
makeAutoObservable(this);
}
async fetchArticles(params = {}) {
this.loading = true;
this.error = null;
try {
const response = await api.get('/api/articles/', { params });
this.articles = response.data.results || response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch articles';
} finally {
this.loading = false;
}
}
async fetchArticle(id) {
this.loading = true;
this.error = null;
try {
const response = await api.get(`/api/articles/${id}/`);
this.currentArticle = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch article';
} finally {
this.loading = false;
}
}
async createArticle(data) {
this.loading = true;
this.error = null;
try {
const response = await api.post('/api/articles/', data);
return { success: true, article: response.data };
} catch (error) {
this.error = error.response?.data || 'Failed to create article';
return { success: false, error: this.error };
} finally {
this.loading = false;
}
}
async updateArticle(id, data) {
this.loading = true;
this.error = null;
try {
const response = await api.put(`/api/articles/${id}/`, data);
return { success: true, article: response.data };
} catch (error) {
this.error = error.response?.data || 'Failed to update article';
return { success: false, error: this.error };
} finally {
this.loading = false;
}
}
async deleteArticle(id) {
try {
await api.delete(`/api/articles/${id}/`);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to delete article',
};
}
}
async submitArticle(id) {
try {
await api.post(`/api/articles/${id}/submit/`);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to submit article',
};
}
}
async approveArticle(id, reason = '') {
try {
await api.post(`/api/articles/${id}/approve/`, { action: 'approve', reason });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to approve article',
};
}
}
async rejectArticle(id, reason) {
try {
await api.post(`/api/articles/${id}/reject/`, { action: 'reject', reason });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to reject article',
};
}
}
async likeArticle(id) {
try {
const response = await api.post(`/api/articles/${id}/like/`);
return response.data;
} catch (error) {
return null;
}
}
async fetchArticleComments(id) {
try {
const response = await api.get(`/api/articles/${id}/comments/`);
return response.data;
} catch (error) {
return [];
}
}
async fetchArticleStats(id) {
try {
const response = await api.get(`/api/articles/${id}/stats/`);
return response.data;
} catch (error) {
return null;
}
}
clearCurrentArticle() {
this.currentArticle = null;
}
}
export default ArticleStore;

View File

@@ -0,0 +1,44 @@
import { makeAutoObservable } from 'mobx';
import axios from 'axios';
class AuthStore {
token = localStorage.getItem('token') || null;
isAuthenticated = !!localStorage.getItem('token');
constructor() {
makeAutoObservable(this);
}
async login(email, password) {
try {
const response = await axios.post('/api/auth/login/', {
email,
password,
});
this.token = response.data.access;
this.isAuthenticated = true;
localStorage.setItem('token', this.token);
localStorage.setItem('refresh', response.data.refresh);
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || 'Login failed',
};
}
}
logout() {
this.token = null;
this.isAuthenticated = false;
localStorage.removeItem('token');
localStorage.removeItem('refresh');
delete axios.defaults.headers.common['Authorization'];
}
}
export default AuthStore;

View File

@@ -0,0 +1,164 @@
import { makeAutoObservable } from 'mobx';
import api from '../services/api';
class InteractionStore {
comments = [];
ratings = [];
likes = [];
favorites = [];
loading = false;
error = null;
constructor() {
makeAutoObservable(this);
}
// Comments
async createComment(targetType, targetId, content) {
try {
const response = await api.post('/api/comments/', {
target_type: targetType,
target_id: targetId,
content,
});
return { success: true, comment: response.data };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to create comment',
};
}
}
async fetchComments(targetType, targetId) {
try {
const response = await api.get('/api/comments/', {
params: { target_type: targetType, target_id: targetId },
});
this.comments = response.data.results || response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch comments';
}
}
async approveComment(commentId) {
try {
await api.post(`/api/comments/${commentId}/approve_ai/`);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to approve comment',
};
}
}
async rejectComment(commentId, reason) {
try {
await api.post(`/api/comments/${commentId}/reject_ai/`, { reason });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to reject comment',
};
}
}
// Ratings
async createRating(targetType, targetId, score) {
try {
const response = await api.post('/api/ratings/', {
target_type: targetType,
target_id: targetId,
score,
});
return { success: true, rating: response.data };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to create rating',
};
}
}
async fetchRatings(params = {}) {
try {
const response = await api.get('/api/ratings/', { params });
this.ratings = response.data.results || response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch ratings';
}
}
async fetchMyRatings() {
try {
const response = await api.get('/api/ratings/my_ratings/');
this.ratings = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch my ratings';
}
}
// Likes
async toggleLike(targetType, targetId) {
try {
const response = await api.post('/api/likes/toggle/', {
target_type: targetType,
target_id: targetId,
});
return response.data;
} catch (error) {
return null;
}
}
async fetchMyLikes() {
try {
const response = await api.get('/api/likes/my_likes/');
this.likes = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch my likes';
}
}
// Favorites
async toggleFavorite(targetType, targetId) {
try {
const response = await api.post('/api/favorites/toggle/', {
target_type: targetType,
target_id: targetId,
});
return response.data;
} catch (error) {
return null;
}
}
async fetchMyFavorites() {
try {
const response = await api.get('/api/favorites/my_favorites/');
this.favorites = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch my favorites';
}
}
clearComments() {
this.comments = [];
}
clearRatings() {
this.ratings = [];
}
clearLikes() {
this.likes = [];
}
clearFavorites() {
this.favorites = [];
}
}
export default InteractionStore;

View File

@@ -0,0 +1,139 @@
import { makeAutoObservable } from 'mobx';
import api from '../services/api';
class RegionStore {
regions = [];
currentRegion = null;
loading = false;
error = null;
constructor() {
makeAutoObservable(this);
}
async fetchRegions() {
this.loading = true;
this.error = null;
try {
const response = await api.get('/api/regions/');
this.regions = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch regions';
} finally {
this.loading = false;
}
}
async fetchRegion(id) {
this.loading = true;
this.error = null;
try {
const response = await api.get(`/api/regions/${id}/`);
this.currentRegion = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch region';
} finally {
this.loading = false;
}
}
async fetchProvinces() {
this.loading = true;
this.error = null;
try {
const response = await api.get('/api/regions/provinces/');
this.regions = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch provinces';
} finally {
this.loading = false;
}
}
async fetchChildren(parentId) {
this.loading = true;
this.error = null;
try {
const response = await api.get(`/api/regions/${parentId}/children/`);
return response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch children';
return [];
} finally {
this.loading = false;
}
}
async fetchRegionArticles(regionId) {
this.loading = true;
this.error = null;
try {
const response = await api.get(`/api/regions/${regionId}/articles/`);
return response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch region articles';
return [];
} finally {
this.loading = false;
}
}
async fetchRegionServices(regionId) {
this.loading = true;
this.error = null;
try {
const response = await api.get(`/api/regions/${regionId}/services/`);
return response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch region services';
return [];
} finally {
this.loading = false;
}
}
async rateRegion(regionId, score) {
try {
await api.post(`/api/regions/${regionId}/rate/`, { score });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to rate region',
};
}
}
async getRegionRating(regionId) {
try {
const response = await api.get(`/api/regions/${regionId}/my_rating/`);
return response.data.score;
} catch (error) {
return null;
}
}
async favoriteRegion(regionId) {
try {
await api.post(`/api/regions/${regionId}/favorite/`);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to favorite region',
};
}
}
clearCurrentRegion() {
this.currentRegion = null;
}
}
export default RegionStore;

View File

@@ -0,0 +1,164 @@
import { makeAutoObservable } from 'mobx';
import api from '../services/api';
class ServiceStore {
services = [];
currentService = null;
loading = false;
error = null;
constructor() {
makeAutoObservable(this);
}
async fetchServices(params = {}) {
this.loading = true;
this.error = null;
try {
const response = await api.get('/api/services/', { params });
this.services = response.data.results || response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch services';
} finally {
this.loading = false;
}
}
async fetchService(id) {
this.loading = true;
this.error = null;
try {
const response = await api.get(`/api/services/${id}/`);
this.currentService = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch service';
} finally {
this.loading = false;
}
}
async createService(data) {
this.loading = true;
this.error = null;
try {
const response = await api.post('/api/services/', data);
return { success: true, service: response.data };
} catch (error) {
this.error = error.response?.data || 'Failed to create service';
return { success: false, error: this.error };
} finally {
this.loading = false;
}
}
async updateService(id, data) {
this.loading = true;
this.error = null;
try {
const response = await api.put(`/api/services/${id}/`, data);
return { success: true, service: response.data };
} catch (error) {
this.error = error.response?.data || 'Failed to update service';
return { success: false, error: this.error };
} finally {
this.loading = false;
}
}
async deleteService(id) {
try {
await api.delete(`/api/services/${id}/`);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to delete service',
};
}
}
async submitService(id) {
try {
await api.post(`/api/services/${id}/submit/`);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to submit service',
};
}
}
async approveService(id, reason = '') {
try {
await api.post(`/api/services/${id}/approve/`, { action: 'approve', reason });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to approve service',
};
}
}
async rejectService(id, reason) {
try {
await api.post(`/api/services/${id}/reject/`, { action: 'reject', reason });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to reject service',
};
}
}
async likeService(id) {
try {
const response = await api.post(`/api/services/${id}/like/`);
return response.data;
} catch (error) {
return null;
}
}
async rateService(id, score) {
try {
await api.post(`/api/services/${id}/rate/`, { score });
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data || 'Failed to rate service',
};
}
}
async fetchServiceComments(id) {
try {
const response = await api.get(`/api/services/${id}/comments/`);
return response.data;
} catch (error) {
return [];
}
}
async fetchServiceStats(id) {
try {
const response = await api.get(`/api/services/${id}/stats/`);
return response.data;
} catch (error) {
return null;
}
}
clearCurrentService() {
this.currentService = null;
}
}
export default ServiceStore;

View File

@@ -0,0 +1,32 @@
import { makeAutoObservable } from 'mobx';
import axios from 'axios';
class UserStore {
user = null;
loading = false;
error = null;
constructor() {
makeAutoObservable(this);
}
async fetchCurrentUser() {
this.loading = true;
this.error = null;
try {
const response = await axios.get('/api/users/me/');
this.user = response.data;
} catch (error) {
this.error = error.response?.data || 'Failed to fetch user';
} finally {
this.loading = false;
}
}
clearUser() {
this.user = null;
}
}
export default UserStore;

View File

@@ -0,0 +1,24 @@
import { createGlobalStyle } from 'styled-components';
const GlobalStyle = createGlobalStyle`
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
`;
export default GlobalStyle;

13
frontend/start.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
echo "🚀 Starting React Frontend..."
# 检查 node_modules
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
npm install
fi
# 启动开发服务器
echo "🎉 Starting development server on http://localhost:3000"
npm start