feat: 前端页面开发 - 首页/城市列表/区域详情 + 示例数据导入
This commit is contained in:
@@ -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;
|
||||
|
||||
68
city-manual/frontend/src/api.js
Normal file
68
city-manual/frontend/src/api.js
Normal 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;
|
||||
171
city-manual/frontend/src/pages/Cities.css
Normal file
171
city-manual/frontend/src/pages/Cities.css
Normal 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;
|
||||
}
|
||||
}
|
||||
135
city-manual/frontend/src/pages/Cities.jsx
Normal file
135
city-manual/frontend/src/pages/Cities.jsx
Normal 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;
|
||||
228
city-manual/frontend/src/pages/Home.css
Normal file
228
city-manual/frontend/src/pages/Home.css
Normal 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;
|
||||
}
|
||||
}
|
||||
101
city-manual/frontend/src/pages/Home.jsx
Normal file
101
city-manual/frontend/src/pages/Home.jsx
Normal 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;
|
||||
335
city-manual/frontend/src/pages/RegionDetail.css
Normal file
335
city-manual/frontend/src/pages/RegionDetail.css
Normal 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;
|
||||
}
|
||||
}
|
||||
224
city-manual/frontend/src/pages/RegionDetail.jsx
Normal file
224
city-manual/frontend/src/pages/RegionDetail.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user