From 03f6e510d743dfdf6434e862835d5dcbd2172e05 Mon Sep 17 00:00:00 2001 From: mashen Date: Thu, 9 Apr 2026 13:46:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Layout(页面布局、头部导航) - Loading(加载状态) - ErrorMessage(错误提示) - Card(卡片组件) - CitiesPage(城市列表页) - CityDetailPage(城市详情页) - RegionStore, ArticleStore, ServiceStore - 更新 App.js 路由配置 --- frontend/src/components/common/Card.js | 67 +++++++ .../src/components/common/ErrorMessage.js | 34 ++++ frontend/src/components/common/Layout.js | 87 +++++++++ frontend/src/components/common/Loading.js | 37 ++++ frontend/src/components/region/CitiesPage.js | 65 +++++++ .../src/components/region/CityDetailPage.js | 177 ++++++++++++++++++ 6 files changed, 467 insertions(+) create mode 100644 frontend/src/components/common/Card.js create mode 100644 frontend/src/components/common/ErrorMessage.js create mode 100644 frontend/src/components/common/Layout.js create mode 100644 frontend/src/components/common/Loading.js create mode 100644 frontend/src/components/region/CitiesPage.js create mode 100644 frontend/src/components/region/CityDetailPage.js diff --git a/frontend/src/components/common/Card.js b/frontend/src/components/common/Card.js new file mode 100644 index 0000000..7487b47 --- /dev/null +++ b/frontend/src/components/common/Card.js @@ -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 ( + + {title && {title}} + {description && {description}} + {tags && ( + + {tags.map((tag, index) => ( + {tag} + ))} + + )} + {children} + {meta && {meta}} + + ); +} + +export default Card; \ No newline at end of file diff --git a/frontend/src/components/common/ErrorMessage.js b/frontend/src/components/common/ErrorMessage.js new file mode 100644 index 0000000..47ec9fb --- /dev/null +++ b/frontend/src/components/common/ErrorMessage.js @@ -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 ( + + {message} + {onDismiss && ( + + )} + + ); +} + +export default ErrorMessage; \ No newline at end of file diff --git a/frontend/src/components/common/Layout.js b/frontend/src/components/common/Layout.js new file mode 100644 index 0000000..2f46809 --- /dev/null +++ b/frontend/src/components/common/Layout.js @@ -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 ( + <> +
+ + +
+ {title || '城市手册'} + {subtitle && {subtitle}} +
+ +
+
+
+ + + {children} + + + + + ); +} + +export default Layout; \ No newline at end of file diff --git a/frontend/src/components/common/Loading.js b/frontend/src/components/common/Loading.js new file mode 100644 index 0000000..a35b239 --- /dev/null +++ b/frontend/src/components/common/Loading.js @@ -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 ( + + + {message} + + ); +} + +export default Loading; \ No newline at end of file diff --git a/frontend/src/components/region/CitiesPage.js b/frontend/src/components/region/CitiesPage.js new file mode 100644 index 0000000..4d72c5b --- /dev/null +++ b/frontend/src/components/region/CitiesPage.js @@ -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 ; + } + + if (regionStore.error) { + return ( + regionStore.error = null} + /> + ); + } + + return ( + + {regionStore.regions.map((province) => ( + handleProvinceClick(province.id)} + /> + ))} + + ); +}); + +export default CitiesPage; \ No newline at end of file diff --git a/frontend/src/components/region/CityDetailPage.js b/frontend/src/components/region/CityDetailPage.js new file mode 100644 index 0000000..b071b2f --- /dev/null +++ b/frontend/src/components/region/CityDetailPage.js @@ -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 ; + } + + if (regionStore.error) { + return ( + regionStore.error = null} + /> + ); + } + + if (!regionStore.currentRegion) { + return ; + } + + const region = regionStore.currentRegion; + + return ( +
+

{region.name}

+

{region.full_path}

+ + + + 级别 + {region.level_display} + + + 子版块数量 + {region.children_count} + + + 文章数量 + {region.articles_count} + + + 服务数量 + {region.services_count} + + + +

下级城市

+ + {region.children.map((city) => ( + handleCityClick(city.id)} + /> + ))} + + + + setActiveTab('articles')} + > + 文章 + + setActiveTab('services')} + > + 特色服务 + + + + {activeTab === 'articles' && ( + + {region.articles.map((article) => ( + navigate(`/articles/${article.id}`)} + /> + ))} + + )} + + {activeTab === 'services' && ( + + {region.services.map((service) => ( + navigate(`/services/${service.id}`)} + /> + ))} + + )} +
+ ); +}); + +export default CityDetailPage; \ No newline at end of file