feat: 前端页面开发 - 首页/城市列表/区域详情 + 示例数据导入

This commit is contained in:
root
2026-04-10 12:18:09 +00:00
committed by maoshen
parent 432345c249
commit a13b9c5ef1
20 changed files with 4190 additions and 115 deletions

View File

@@ -1,121 +1,68 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import Home from './pages/Home';
import Cities from './pages/Cities';
import RegionDetail from './pages/RegionDetail';
import './App.css';
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<Router>
<div className="app">
{/* Navigation */}
<nav className="navbar">
<div className="container">
<Link to="/" className="logo">
📖 城市手册
</Link>
<div className="nav-links">
<Link to="/cities">城市</Link>
<Link to="/articles">文章</Link>
<Link to="/services">服务</Link>
<Link to="/login" className="btn-login">登录</Link>
</div>
</div>
</nav>
<div className="ticks"></div>
{/* Main Content */}
<main className="main-content">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/cities" element={<Cities />} />
<Route path="/cities/:provinceId" element={<Cities />} />
<Route path="/region/:id" element={<RegionDetail />} />
<Route path="/article/:id" element={<ArticleDetail />} />
<Route path="/service/:id" element={<ServiceDetail />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Routes>
</main>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
{/* Footer */}
<footer className="footer">
<div className="container">
<p>© 2026 城市手册 - 记录每座城市的独特魅力</p>
</div>
</footer>
</div>
</Router>
);
}
export default App
// 占位组件 - 后续完善
function ArticleDetail() {
return <div className="container" style={{ padding: '40px 20px' }}><h1>文章详情页</h1><p>开发中...</p></div>;
}
function ServiceDetail() {
return <div className="container" style={{ padding: '40px 20px' }}><h1>服务详情页</h1><p>开发中...</p></div>;
}
function Login() {
return <div className="container" style={{ padding: '40px 20px' }}><h1>登录</h1><p>开发中...</p></div>;
}
function Register() {
return <div className="container" style={{ padding: '40px 20px' }}><h1>注册</h1><p>开发中...</p></div>;
}
export default App;

View File

@@ -0,0 +1,68 @@
import axios from 'axios';
const API_BASE = 'http://localhost:8000/api';
const api = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器 - 添加 token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器 - 处理错误
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export const regionsApi = {
getList: () => api.get('/regions/'),
getDetail: (id) => api.get(`/regions/${id}/`),
getProvinces: () => api.get('/regions/provinces/'),
getChildren: (id) => api.get(`/regions/${id}/children/`),
};
export const articlesApi = {
getList: (params) => api.get('/articles/', { params }),
getDetail: (id) => api.get(`/articles/${id}/`),
create: (data) => api.post('/articles/', data),
};
export const servicesApi = {
getList: (params) => api.get('/services/', { params }),
getDetail: (id) => api.get(`/services/${id}/`),
create: (data) => api.post('/services/', data),
};
export const usersApi = {
register: (data) => api.post('/register/', data),
login: (data) => api.post('/token/', data),
getMe: () => api.get('/users/me/'),
};
export const commentsApi = {
getList: () => api.get('/comments/'),
create: (data) => api.post('/comments/', data),
};
export const ratingsApi = {
getList: () => api.get('/ratings/'),
create: (data) => api.post('/ratings/', data),
};
export default api;

View File

@@ -0,0 +1,171 @@
.cities-page {
min-height: 100vh;
padding: 40px 20px;
background: #f8f9fa;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
/* Breadcrumb */
.breadcrumb {
margin-bottom: 30px;
font-size: 0.95rem;
}
.breadcrumb a {
color: #667eea;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb .separator {
margin: 0 10px;
color: #999;
}
/* Region Header */
.region-header {
background: white;
padding: 40px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.region-header h1 {
font-size: 2.5rem;
color: #333;
margin-bottom: 15px;
}
.region-description {
color: #666;
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 20px;
}
.region-stats {
display: flex;
gap: 30px;
}
.stat {
color: #667eea;
font-weight: bold;
font-size: 1rem;
}
/* Region Grid */
.region-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.region-card {
background: white;
padding: 30px 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: #333;
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
}
.region-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.region-icon {
font-size: 3rem;
margin-bottom: 15px;
}
.region-card h3 {
font-size: 1.3rem;
margin-bottom: 10px;
color: #333;
}
.region-level {
color: #667eea;
font-size: 0.9rem;
margin-bottom: 8px;
}
.region-children {
color: #999;
font-size: 0.85rem;
}
/* Empty State */
.empty-state {
background: white;
padding: 60px 40px;
border-radius: 12px;
text-align: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.empty-state p {
color: #666;
font-size: 1.1rem;
margin-bottom: 20px;
}
.btn-link {
color: #667eea;
text-decoration: none;
font-weight: bold;
}
.btn-link:hover {
text-decoration: underline;
}
/* Loading */
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5rem;
color: #666;
}
/* Responsive */
@media (max-width: 768px) {
.cities-page {
padding: 20px 15px;
}
.region-header {
padding: 25px 20px;
}
.region-header h1 {
font-size: 1.8rem;
}
.region-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 15px;
}
.region-card {
padding: 20px 15px;
}
.region-icon {
font-size: 2.5rem;
}
}

View File

@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { regionsApi } from '../api';
import './Cities.css';
function Cities() {
const { provinceId } = useParams();
const [regions, setRegions] = useState([]);
const [currentRegion, setCurrentRegion] = useState(null);
const [loading, setLoading] = useState(true);
const [breadcrumb, setBreadcrumb] = useState([]);
useEffect(() => {
loadRegions();
}, [provinceId]);
async function loadRegions() {
setLoading(true);
try {
if (provinceId) {
// 加载省份详情和子区域
const [detailRes, childrenRes] = await Promise.all([
regionsApi.getDetail(provinceId),
regionsApi.getChildren(provinceId),
]);
setCurrentRegion(detailRes.data);
setRegions(childrenRes.data);
setBreadcrumb([
{ id: null, name: '全部省份' },
{ id: provinceId, name: detailRes.data.name },
]);
} else {
// 加载所有省份
const res = await regionsApi.getProvinces();
setRegions(res.data);
setCurrentRegion(null);
setBreadcrumb([{ id: null, name: '全部省份' }]);
}
} catch (error) {
console.error('加载区域失败:', error);
} finally {
setLoading(false);
}
}
if (loading) {
return <div className="loading">加载中...</div>;
}
return (
<div className="cities-page">
<div className="container">
{/* 面包屑导航 */}
<nav className="breadcrumb">
{breadcrumb.map((item, index) => (
<span key={item.id || 'home'}>
{index > 0 && <span className="separator">/</span>}
{item.id ? (
<Link to={item.id === provinceId ? '/cities' : `/cities/${item.id}`}>
{item.name}
</Link>
) : (
<Link to="/cities">{item.name}</Link>
)}
</span>
))}
</nav>
{/* 当前区域信息 */}
{currentRegion && (
<div className="region-header">
<h1>{currentRegion.name}</h1>
{currentRegion.description && (
<p className="region-description">{currentRegion.description}</p>
)}
<div className="region-stats">
<span className="stat">
📝 {currentRegion.articles_count || 0} 篇文章
</span>
<span className="stat">
🏪 {currentRegion.services_count || 0} 个服务
</span>
</div>
</div>
)}
{/* 子区域列表 */}
{regions.length > 0 ? (
<div className="region-grid">
{regions.map((region) => (
<Link
key={region.id}
to={region.level === 'province' || !provinceId ? `/cities/${region.id}` : `/region/${region.id}`}
className="region-card"
>
<div className="region-icon">
{region.level === 'province' && '🏔️'}
{region.level === 'city' && '🏙️'}
{region.level === 'county' && '🏘️'}
{region.level === 'town' && '🏡'}
{region.level === 'village' && '🏠'}
</div>
<h3>{region.name}</h3>
<p className="region-level">{getLevelName(region.level)}</p>
{region.children_count > 0 && (
<p className="region-children">{region.children_count} 个下级区域</p>
)}
</Link>
))}
</div>
) : (
<div className="empty-state">
<p>暂无下级区域</p>
<Link to={`/articles?region=${provinceId}`} className="btn-link">
查看该区域的文章
</Link>
</div>
)}
</div>
</div>
);
}
function getLevelName(level) {
const map = {
province: '省级',
city: '市级',
county: '县级',
town: '镇级',
village: '村级',
};
return map[level] || level;
}
export default Cities;

View File

@@ -0,0 +1,228 @@
.home {
min-height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5rem;
color: #666;
}
/* Hero Section */
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 120px 20px;
text-align: center;
}
.hero-content h1 {
font-size: 3.5rem;
margin-bottom: 20px;
font-weight: bold;
}
.hero-content p {
font-size: 1.3rem;
margin-bottom: 30px;
opacity: 0.9;
}
.btn-primary {
display: inline-block;
background: white;
color: #667eea;
padding: 15px 40px;
border-radius: 30px;
text-decoration: none;
font-weight: bold;
font-size: 1.1rem;
transition: transform 0.3s, box-shadow 0.3s;
}
.btn-primary:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
/* Section */
.section {
padding: 80px 20px;
}
.section-alt {
background: #f8f9fa;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.section-title {
text-align: center;
font-size: 2rem;
margin-bottom: 50px;
color: #333;
position: relative;
}
.section-title::after {
content: '';
display: block;
width: 60px;
height: 4px;
background: #667eea;
margin: 15px auto 0;
border-radius: 2px;
}
/* Province Grid */
.province-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.province-card {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: #333;
transition: transform 0.3s, box-shadow 0.3s;
border: 2px solid transparent;
}
.province-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.province-card h3 {
font-size: 1.5rem;
margin-bottom: 10px;
color: #667eea;
}
.province-card p {
color: #666;
font-size: 0.95rem;
}
/* Article Grid */
.article-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 25px;
}
.article-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: #333;
transition: transform 0.3s, box-shadow 0.3s;
}
.article-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.article-card h3 {
font-size: 1.3rem;
margin-bottom: 10px;
color: #333;
}
.article-meta {
color: #667eea;
font-size: 0.9rem;
margin-bottom: 15px;
}
.article-excerpt {
color: #666;
line-height: 1.6;
font-size: 0.95rem;
}
/* Service Grid */
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.service-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: #333;
transition: transform 0.3s, box-shadow 0.3s;
position: relative;
overflow: hidden;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.service-category {
position: absolute;
top: 15px;
right: 15px;
background: #667eea;
color: white;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.8rem;
}
.service-card h3 {
font-size: 1.2rem;
margin-bottom: 10px;
color: #333;
}
.service-location {
color: #666;
font-size: 0.9rem;
margin-bottom: 10px;
}
.service-rating {
color: #f5a623;
font-weight: bold;
font-size: 0.95rem;
}
/* Responsive */
@media (max-width: 768px) {
.hero-content h1 {
font-size: 2.5rem;
}
.hero-content p {
font-size: 1.1rem;
}
.section {
padding: 50px 15px;
}
.section-title {
font-size: 1.6rem;
}
}

View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { regionsApi, articlesApi, servicesApi } from '../api';
import './Home.css';
function Home() {
const [provinces, setProvinces] = useState([]);
const [featuredArticles, setFeaturedArticles] = useState([]);
const [featuredServices, setFeaturedServices] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
async function loadData() {
try {
const [provincesRes, articlesRes, servicesRes] = await Promise.all([
regionsApi.getProvinces(),
articlesApi.getList({ limit: 6 }),
servicesApi.getList({ limit: 6 }),
]);
setProvinces(provincesRes.data.slice(0, 6));
setFeaturedArticles(articlesRes.data.results || articlesRes.data.slice(0, 6));
setFeaturedServices(servicesRes.data.results || servicesRes.data.slice(0, 6));
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
}
if (loading) {
return <div className="loading">加载中...</div>;
}
return (
<div className="home">
{/* Hero Section */}
<section className="hero">
<div className="hero-content">
<h1>城市手册</h1>
<p>探索每座城市的独特魅力</p>
<Link to="/cities" className="btn-primary">开始探索</Link>
</div>
</section>
{/* 省份导航 */}
<section className="section">
<div className="container">
<h2 className="section-title">热门省份</h2>
<div className="province-grid">
{provinces.map((province) => (
<Link key={province.id} to={`/cities/${province.id}`} className="province-card">
<h3>{province.name}</h3>
<p>{province.level === 'province' ? '省级行政区' : province.level}</p>
</Link>
))}
</div>
</div>
</section>
{/* 精选文章 */}
<section className="section section-alt">
<div className="container">
<h2 className="section-title">精选内容</h2>
<div className="article-grid">
{featuredArticles.map((article) => (
<Link key={article.id} to={`/article/${article.id}`} className="article-card">
<h3>{article.title}</h3>
<p className="article-meta">{article.region?.name} · {article.content_type}</p>
<p className="article-excerpt">{article.content?.substring(0, 100)}...</p>
</Link>
))}
</div>
</div>
</section>
{/* 特色服务 */}
<section className="section">
<div className="container">
<h2 className="section-title">特色服务</h2>
<div className="service-grid">
{featuredServices.map((service) => (
<Link key={service.id} to={`/service/${service.id}`} className="service-card">
<div className="service-category">{service.category}</div>
<h3>{service.name}</h3>
<p className="service-location">{service.region?.name}</p>
{service.rating_average > 0 && (
<div className="service-rating"> {service.rating_average.toFixed(1)}</div>
)}
</Link>
))}
</div>
</div>
</section>
</div>
);
}
export default Home;

View File

@@ -0,0 +1,335 @@
.region-detail {
min-height: 100vh;
background: #f8f9fa;
}
/* Header */
.region-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 60px 20px 40px;
}
.region-header .container {
max-width: 1200px;
margin: 0 auto;
}
.breadcrumb {
margin-bottom: 20px;
opacity: 0.9;
}
.breadcrumb a {
color: white;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb .separator {
margin: 0 10px;
}
.region-header h1 {
font-size: 3rem;
margin-bottom: 15px;
font-weight: bold;
}
.description {
font-size: 1.2rem;
line-height: 1.6;
margin-bottom: 20px;
opacity: 0.95;
}
.tags {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tag {
background: rgba(255, 255, 255, 0.2);
padding: 6px 15px;
border-radius: 20px;
font-size: 0.9rem;
}
/* Tabs */
.tabs-container {
background: white;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 100;
}
.tabs {
display: flex;
gap: 5px;
overflow-x: auto;
}
.tab {
padding: 18px 30px;
border: none;
background: none;
font-size: 1rem;
color: #666;
cursor: pointer;
position: relative;
transition: color 0.3s;
white-space: nowrap;
}
.tab:hover {
color: #667eea;
}
.tab.active {
color: #667eea;
font-weight: bold;
}
.tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: #667eea;
}
/* Content */
.content {
padding: 40px 20px;
}
/* Overview */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: white;
padding: 30px;
border-radius: 12px;
text-align: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.stat-number {
font-size: 3rem;
font-weight: bold;
color: #667eea;
margin-bottom: 10px;
}
.stat-label {
color: #666;
font-size: 1rem;
}
.children-section {
background: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.children-section h2 {
margin-bottom: 25px;
color: #333;
}
.region-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.region-card {
background: #f8f9fa;
padding: 25px;
border-radius: 12px;
text-decoration: none;
color: #333;
text-align: center;
transition: transform 0.3s, box-shadow 0.3s;
}
.region-card:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
background: white;
}
.region-card h3 {
font-size: 1.2rem;
margin-bottom: 8px;
color: #667eea;
}
.region-card p {
color: #666;
font-size: 0.9rem;
}
/* Articles & Services List */
.articles-list,
.services-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.article-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: #333;
transition: transform 0.3s, box-shadow 0.3s;
}
.article-card:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
}
.article-card h3 {
font-size: 1.2rem;
margin-bottom: 12px;
color: #333;
}
.meta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.type {
color: #667eea;
background: #f0f2ff;
padding: 4px 10px;
border-radius: 4px;
}
.date {
color: #999;
}
.service-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-decoration: none;
color: #333;
transition: transform 0.3s, box-shadow 0.3s;
}
.service-card:hover {
transform: translateY(-3px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.service-header h3 {
font-size: 1.2rem;
color: #333;
}
.category {
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
}
.service-description {
color: #666;
line-height: 1.6;
margin-bottom: 15px;
}
.rating {
color: #f5a623;
font-weight: bold;
font-size: 0.95rem;
}
/* Empty State */
.empty {
background: white;
padding: 60px 40px;
border-radius: 12px;
text-align: center;
color: #666;
font-size: 1.1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* Loading */
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 1.5rem;
color: #666;
}
/* Responsive */
@media (max-width: 768px) {
.region-header {
padding: 40px 15px 30px;
}
.region-header h1 {
font-size: 2rem;
}
.description {
font-size: 1rem;
}
.tabs {
padding: 0 15px;
}
.tab {
padding: 15px 20px;
font-size: 0.95rem;
}
.content {
padding: 25px 15px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.articles-list,
.services-list {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,224 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { regionsApi, articlesApi, servicesApi } from '../api';
import './RegionDetail.css';
function RegionDetail() {
const { id } = useParams();
const [region, setRegion] = useState(null);
const [articles, setArticles] = useState([]);
const [services, setServices] = useState([]);
const [children, setChildren] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('overview');
useEffect(() => {
loadData();
}, [id]);
async function loadData() {
setLoading(true);
try {
const [regionRes, articlesRes, servicesRes, childrenRes] = await Promise.all([
regionsApi.getDetail(id),
articlesApi.getList({ region: id, limit: 10 }),
servicesApi.getList({ region: id, limit: 10 }),
regionsApi.getChildren(id),
]);
setRegion(regionRes.data);
setArticles(articlesRes.data.results || articlesRes.data);
setServices(servicesRes.data.results || servicesRes.data);
setChildren(childrenRes.data);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setLoading(false);
}
}
if (loading || !region) {
return <div className="loading">加载中...</div>;
}
return (
<div className="region-detail">
{/* Header */}
<header className="region-header">
<div className="container">
<nav className="breadcrumb">
<Link to="/cities">城市</Link>
<span className="separator">/</span>
<span>{region.name}</span>
</nav>
<h1>{region.name}</h1>
{region.description && <p className="description">{region.description}</p>}
<div className="tags">
<span className="tag">{getLevelName(region.level)}</span>
{region.code && <span className="tag">代码{region.code}</span>}
</div>
</div>
</header>
{/* Tabs */}
<div className="tabs-container">
<div className="container">
<div className="tabs">
<button
className={`tab ${activeTab === 'overview' ? 'active' : ''}`}
onClick={() => setActiveTab('overview')}
>
概览
</button>
<button
className={`tab ${activeTab === 'articles' ? 'active' : ''}`}
onClick={() => setActiveTab('articles')}
>
文章 ({articles.length})
</button>
<button
className={`tab ${activeTab === 'services' ? 'active' : ''}`}
onClick={() => setActiveTab('services')}
>
服务 ({services.length})
</button>
<button
className={`tab ${activeTab === 'subregions' ? 'active' : ''}`}
onClick={() => setActiveTab('subregions')}
>
下级区域 ({children.length})
</button>
</div>
</div>
</div>
{/* Content */}
<div className="container content">
{activeTab === 'overview' && (
<div className="overview">
<div className="stats-grid">
<div className="stat-card">
<div className="stat-number">{region.articles_count || 0}</div>
<div className="stat-label">文章</div>
</div>
<div className="stat-card">
<div className="stat-number">{region.services_count || 0}</div>
<div className="stat-label">服务</div>
</div>
<div className="stat-card">
<div className="stat-number">{children.length}</div>
<div className="stat-label">下级区域</div>
</div>
</div>
{children.length > 0 && (
<div className="children-section">
<h2>下级区域</h2>
<div className="region-grid">
{children.slice(0, 6).map((child) => (
<Link key={child.id} to={`/region/${child.id}`} className="region-card">
<h3>{child.name}</h3>
<p>{getLevelName(child.level)}</p>
</Link>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'articles' && (
<div className="articles-list">
{articles.length > 0 ? (
articles.map((article) => (
<Link key={article.id} to={`/article/${article.id}`} className="article-card">
<h3>{article.title}</h3>
<div className="meta">
<span className="type">{getContentTypeName(article.content_type)}</span>
<span className="date">{new Date(article.created_at).toLocaleDateString('zh-CN')}</span>
</div>
</Link>
))
) : (
<div className="empty">暂无文章</div>
)}
</div>
)}
{activeTab === 'services' && (
<div className="services-list">
{services.length > 0 ? (
services.map((service) => (
<Link key={service.id} to={`/service/${service.id}`} className="service-card">
<div className="service-header">
<h3>{service.name}</h3>
<span className="category">{getCategoryName(service.category)}</span>
</div>
<p className="description">{service.description?.substring(0, 100)}...</p>
{service.rating_average > 0 && (
<div className="rating"> {service.rating_average.toFixed(1)} ({service.rating_count}条评价)</div>
)}
</Link>
))
) : (
<div className="empty">暂无服务</div>
)}
</div>
)}
{activeTab === 'subregions' && (
<div className="children-list">
{children.length > 0 ? (
<div className="region-grid">
{children.map((child) => (
<Link key={child.id} to={`/region/${child.id}`} className="region-card">
<h3>{child.name}</h3>
<p>{getLevelName(child.level)}</p>
</Link>
))}
</div>
) : (
<div className="empty">暂无下级区域</div>
)}
</div>
)}
</div>
</div>
);
}
function getLevelName(level) {
const map = {
province: '省级',
city: '市级',
county: '县级',
town: '镇级',
village: '村级',
};
return map[level] || level;
}
function getContentTypeName(type) {
const map = {
city_info: '城市信息',
history: '历史',
culture: '文化',
practical: '实用信息',
life: '生活指南',
};
return map[type] || type;
}
function getCategoryName(category) {
const map = {
clothing: '衣',
food: '食',
housing: '住',
transportation: '行',
entertainment: '娱乐',
tourism: '旅游',
culture: '文化',
};
return map[category] || category;
}
export default RegionDetail;