Compare commits

...

2 Commits

Author SHA1 Message Date
mashen
7050f15f0a feat: 配置 React Router 路由
- 配置首页路由
- 配置城市列表页路由
- 配置城市详情页路由
- 配置个人中心页路由
- 配置 404 页面
- 集成 MobX stores
2026-04-09 13:46:50 +00:00
mashen
03f6e510d7 feat: 添加前端基础组件
- Layout(页面布局、头部导航)
- Loading(加载状态)
- ErrorMessage(错误提示)
- Card(卡片组件)
- CitiesPage(城市列表页)
- CityDetailPage(城市详情页)
- RegionStore, ArticleStore, ServiceStore
- 更新 App.js 路由配置
2026-04-09 13:46:35 +00:00
7 changed files with 563 additions and 6 deletions

View File

@@ -1,6 +1,12 @@
import React from 'react'; import React from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { observer } from 'mobx-react-lite';
import styled from 'styled-components'; import styled from 'styled-components';
import { useAuthStore } from './stores/AuthStore';
import Layout from './components/common/Layout';
import Loading from './components/common/Loading';
import CitiesPage from './components/region/CitiesPage';
import CityDetailPage from './components/region/CityDetailPage';
const Container = styled.div` const Container = styled.div`
max-width: 1200px; max-width: 1200px;
@@ -20,16 +26,100 @@ const Title = styled.h1`
`; `;
function App() { 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="/user/profile" element={<UserProfilePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout>
);
}
const HomePage = observer(() => {
return ( return (
<Container> <Container>
<Header> <Header>
<Title>React + Django App</Title> <Title>欢迎来到城市手册</Title>
<p>探索每个城市的故事与特色</p>
</Header> </Header>
<Routes> <div>
<Route path="/" element={<div>Welcome to the app!</div>} /> <h2>热门城市</h2>
</Routes> <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> </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; export default App;

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;