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

Binary file not shown.

View File

@@ -0,0 +1,225 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from datetime import timedelta
from regions.models import Region, ModeratorApplication
from users.models import User
from content.models import Article
from services.models import FeaturedService
class Command(BaseCommand):
help = '导入示例城市数据'
def handle(self, *args, **options):
self.stdout.write('开始导入示例数据...')
# 创建示例用户
user, _ = User.objects.get_or_create(
username='demo',
defaults={
'email': 'demo@citymanual.com',
'is_verified': True,
}
)
user.set_password('demo123')
user.save()
# 创建省级区域
provinces_data = [
{'name': '北京市', 'code': '110000', 'description': '中华人民共和国首都,全国政治中心、文化中心、国际交往中心、科技创新中心。'},
{'name': '上海市', 'code': '310000', 'description': '中国最大的经济中心和港口城市,国际金融中心,国际贸易中心。'},
{'name': '广东省', 'code': '440000', 'description': '中国经济最发达的省份之一,改革开放的前沿阵地。'},
{'name': '浙江省', 'code': '330000', 'description': '中国东南沿海省份,经济发达,民营经济活跃。'},
{'name': '四川省', 'code': '510000', 'description': '中国西南地区重要省份,天府之国,美食之都。'},
{'name': '陕西省', 'code': '610000', 'description': '中华文明重要发祥地,十三朝古都所在地,历史文化底蕴深厚。'},
]
provinces = []
for prov_data in provinces_data:
province, created = Region.objects.get_or_create(
name=prov_data['name'],
defaults={
'level': 'province',
'code': prov_data['code'],
'description': prov_data['description'],
'is_active': True,
}
)
provinces.append(province)
if created:
self.stdout.write(f'✓ 创建省份:{province.name}')
# 创建市级区域
cities_data = [
{'name': '广州市', 'parent': '广东省', 'code': '440100', 'description': '广东省省会,国家中心城市,国际商贸中心。'},
{'name': '深圳市', 'parent': '广东省', 'code': '440300', 'description': '经济特区,科技创新中心,中国硅谷。'},
{'name': '杭州市', 'parent': '浙江省', 'code': '330100', 'description': '浙江省省会,电子商务之都,风景秀丽。'},
{'name': '宁波市', 'parent': '浙江省', 'code': '330200', 'description': '副省级市,计划单列市,重要的港口城市。'},
{'name': '成都市', 'parent': '四川省', 'code': '510100', 'description': '四川省省会,天府之国核心,美食之都。'},
{'name': '绵阳市', 'parent': '四川省', 'code': '510700', 'description': '四川省第二大城市,中国科技城。'},
{'name': '西安市', 'parent': '陕西省', 'code': '610100', 'description': '陕西省省会,十三朝古都,世界历史名城。'},
{'name': '咸阳市', 'parent': '陕西省', 'code': '610400', 'description': '中国第一个封建帝国秦朝的都城所在地。'},
]
cities = []
for city_data in cities_data:
parent = Region.objects.filter(name=city_data['parent']).first()
if parent:
city, created = Region.objects.get_or_create(
name=city_data['name'],
defaults={
'level': 'city',
'parent': parent,
'code': city_data['code'],
'description': city_data['description'],
'is_active': True,
}
)
cities.append(city)
if created:
self.stdout.write(f'✓ 创建城市:{city.name}')
# 创建示例文章
articles_data = [
{
'title': '北京故宫游览完全攻略',
'content': '故宫,旧称紫禁城,是中国明清两代的皇家宫殿,位于北京中轴线的中心。故宫以三大殿为中心,占地面积约 72 万平方米,建筑面积约 15 万平方米,有大小宫殿七十多座,房屋九千余间。是世界上现存规模最大、保存最为完整的木质结构古建筑之一。\n\n游览建议:\n1. 最佳游览时间:春秋两季\n2. 建议游览时长3-4 小时\n3. 必游景点:太和殿、乾清宫、御花园\n4. 门票:旺季 60 元,淡季 40 元',
'region_name': '北京市',
'content_type': 'tourism',
},
{
'title': '上海外滩历史与现状',
'content': '外滩位于上海市黄浦区的黄浦江畔即外黄浦滩为中国历史文化街区。1844 年(清道光廿四年)起,外滩这一带被划为英国租界,成为上海十里洋场的真实写照,也是旧上海租界区以及整个上海近代城市开始的起点。\n\n外滩全长 1.5 千米,南起延安东路,北至苏州河上的外白渡桥,东面即黄浦江,西面是历史风格建筑群。',
'region_name': '上海市',
'content_type': 'history',
},
{
'title': '广州早茶文化指南',
'content': '广州早茶是广州饮食文化的重要组成部分,也是岭南文化的重要体现。广州人饮早茶,不仅是品尝美食,更是一种社交方式和生活方式。\n\n经典茶点:\n- 虾饺:晶莹剔透,鲜美弹牙\n- 烧卖:皮薄馅足,肉香四溢\n- 肠粉:滑嫩爽口,酱汁鲜美\n- 叉烧包:甜咸适中,松软可口\n\n推荐茶楼:陶陶居、广州酒家、莲香楼',
'region_name': '广州市',
'content_type': 'culture',
},
{
'title': '深圳科技创新发展报告',
'content': '深圳作为中国改革开放的窗口和经济特区40 多年来从一个小渔村发展成为国际化创新型城市。深圳拥有华为、腾讯、大疆等众多知名科技企业,被誉为"中国硅谷"\n\n2025 年深圳 PCT 国际专利申请量连续多年居全国城市首位,战略性新兴产业增加值占 GDP 比重超过 40%',
'region_name': '深圳市',
'content_type': 'city_info',
},
{
'title': '杭州西湖十景详解',
'content': '西湖十景是杭州西湖最具代表性的十个景点,形成于南宋时期。每个景点都有其独特的自然风光和文化内涵。\n\n十景包括:\n1. 苏堤春晓\n2. 曲院风荷\n3. 平湖秋月\n4. 断桥残雪\n5. 花港观鱼\n6. 柳浪闻莺\n7. 三潭印月\n8. 双峰插云\n9. 雷峰夕照\n10. 南屏晚钟',
'region_name': '杭州市',
'content_type': 'tourism',
},
{
'title': '成都美食地图',
'content': '成都,被联合国教科文组织授予"美食之都"称号,是中国乃至世界的美食天堂。川菜以其麻、辣、鲜、香的特色闻名世界。\n\n必吃美食:\n- 火锅:麻辣鲜香,回味无穷\n- 串串香:成都特色,街头美食\n- 麻婆豆腐:经典川菜,麻辣鲜香\n- 夫妻肺片:凉拌菜经典\n- 担担面:传统面食\n\n美食街区:锦里、宽窄巷子、春熙路',
'region_name': '成都市',
'content_type': 'life',
},
{
'title': '西安兵马俑参观指南',
'content': '秦始皇兵马俑博物馆位于西安市临潼区,是中国第一个封建皇帝秦始皇的陵墓陪葬坑,被誉为"世界第八大奇迹"\n\n参观信息:\n- 开放时间8:30-18:00\n- 门票120 元\n- 建议游览2-3 小时\n- 最佳季节:春秋两季\n\n兵马俑坑共有三个,其中一号坑最大,展示了完整的军阵布局。',
'region_name': '西安市',
'content_type': 'tourism',
},
]
for article_data in articles_data:
region = Region.objects.filter(name=article_data['region_name']).first()
if region:
article, created = Article.objects.get_or_create(
title=article_data['title'],
defaults={
'content': article_data['content'],
'region': region,
'content_type': article_data['content_type'],
'author': user,
'moderator_status': 'approved',
'moderator_reviewed_at': timezone.now(),
'ai_status': 'approved',
'ai_reviewed_at': timezone.now(),
'publish_status': 'published',
}
)
if created:
self.stdout.write(f'✓ 创建文章:{article.title}')
# 创建示例特色服务
services_data = [
{
'name': '全聚德烤鸭店 (北京)',
'description': '中华老字号,创建于 1864 年,以挂炉烤鸭闻名。全聚德烤鸭以其色泽红润、肉质肥而不腻、外脆里嫩的特点著称。',
'region_name': '北京市',
'category': 'food',
'address': '北京市东城区前门大街 32 号',
'price_range': '人均 150-300 元',
},
{
'name': '上海东方明珠塔',
'description': '上海标志性建筑,高 468 米,集观光、餐饮、娱乐、购物于一体。登上观光层可俯瞰整个上海市区。',
'region_name': '上海市',
'category': 'tourism',
'address': '上海市浦东新区陆家嘴世纪大道 1 号',
'price_range': '门票 199 元起',
},
{
'name': '广州塔 (小蛮腰)',
'description': '广州地标建筑,高 600 米,中国第一高塔。设有观光层、摩天轮、极速云霄等游乐项目。',
'region_name': '广州市',
'category': 'tourism',
'address': '广州市海珠区阅江西路 222 号',
'price_range': '门票 150 元起',
},
{
'name': '杭州楼外楼',
'description': '百年老字号餐厅,创建于 1848 年,以杭帮菜闻名。招牌菜包括西湖醋鱼、龙井虾仁、叫花童鸡等。',
'region_name': '杭州市',
'category': 'food',
'address': '杭州市西湖区孤山路 30 号',
'price_range': '人均 200-400 元',
},
{
'name': '成都宽窄巷子',
'description': '成都著名历史文化街区,由宽巷子、窄巷子、井巷子组成。集美食、购物、文化体验于一体。',
'region_name': '成都市',
'category': 'tourism',
'address': '成都市青羊区长顺上街 127 号',
'price_range': '免费开放',
},
{
'name': '西安大唐不夜城',
'description': '以盛唐文化为背景的主题街区,夜景璀璨,有各种表演、美食、文创店铺,是西安夜生活的代表。',
'region_name': '西安市',
'category': 'entertainment',
'address': '西安市雁塔区曲江新区',
'price_range': '免费开放',
},
]
for service_data in services_data:
region = Region.objects.filter(name=service_data['region_name']).first()
if region:
service, created = FeaturedService.objects.get_or_create(
name=service_data['name'],
defaults={
'description': service_data['description'],
'region': region,
'category': service_data['category'],
'address': service_data.get('address', ''),
'price_range': service_data.get('price_range', ''),
'submitter': user,
'moderator_status': 'approved',
'moderator_reviewed_at': timezone.now(),
'ai_status': 'approved',
'ai_reviewed_at': timezone.now(),
'publish_status': 'published',
}
)
if created:
self.stdout.write(f'✓ 创建服务:{service.name}')
self.stdout.write(self.style.SUCCESS('\n✅ 示例数据导入完成!'))
self.stdout.write('\n测试账号:')
self.stdout.write(' 用户名demo')
self.stdout.write(' 密码demo123')

View File

@@ -0,0 +1,3 @@
#!/bin/bash
cd /root/.openclaw/workspace/city-manual/backend
python3 manage.py seed_data

View File

@@ -0,0 +1,3 @@
#!/bin/bash
cd /root/.openclaw/workspace/city-manual/frontend
npm run dev -- --host 0.0.0.0 --port 3000 2>&1

View File

@@ -0,0 +1,3 @@
#!/bin/bash
cd /root/.openclaw/workspace/city-manual/frontend
npm install --legacy-peer-deps 2>&1

2630
city-manual/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

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" />
<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>
<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>
</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>
{/* Footer */}
<footer className="footer">
<div className="container">
<p>© 2026 城市手册 - 记录每座城市的独特魅力</p>
</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>
</footer>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
</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;