feat: 前端页面开发 - 首页/城市列表/区域详情 + 示例数据导入
This commit is contained in:
Binary file not shown.
0
city-manual/backend/regions/management/__init__.py
Normal file
0
city-manual/backend/regions/management/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
225
city-manual/backend/regions/management/commands/seed_data.py
Normal file
225
city-manual/backend/regions/management/commands/seed_data.py
Normal 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')
|
||||||
3
city-manual/backend/seed.sh
Normal file
3
city-manual/backend/seed.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /root/.openclaw/workspace/city-manual/backend
|
||||||
|
python3 manage.py seed_data
|
||||||
3
city-manual/frontend/dev.sh
Normal file
3
city-manual/frontend/dev.sh
Normal 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
|
||||||
3
city-manual/frontend/install.sh
Normal file
3
city-manual/frontend/install.sh
Normal 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
2630
city-manual/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.15.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@@ -1,121 +1,68 @@
|
|||||||
import { useState } from 'react'
|
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||||
import reactLogo from './assets/react.svg'
|
import Home from './pages/Home';
|
||||||
import viteLogo from './assets/vite.svg'
|
import Cities from './pages/Cities';
|
||||||
import heroImg from './assets/hero.png'
|
import RegionDetail from './pages/RegionDetail';
|
||||||
import './App.css'
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Router>
|
||||||
<section id="center">
|
<div className="app">
|
||||||
<div className="hero">
|
{/* Navigation */}
|
||||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
<nav className="navbar">
|
||||||
<img src={reactLogo} className="framework" alt="React logo" />
|
<div className="container">
|
||||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
<Link to="/" className="logo">
|
||||||
</div>
|
📖 城市手册
|
||||||
<div>
|
</Link>
|
||||||
<h1>Get started</h1>
|
<div className="nav-links">
|
||||||
<p>
|
<Link to="/cities">城市</Link>
|
||||||
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
|
<Link to="/articles">文章</Link>
|
||||||
</p>
|
<Link to="/services">服务</Link>
|
||||||
</div>
|
<Link to="/login" className="btn-login">登录</Link>
|
||||||
<button
|
</div>
|
||||||
className="counter"
|
</div>
|
||||||
onClick={() => setCount((count) => count + 1)}
|
</nav>
|
||||||
>
|
|
||||||
Count is {count}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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">
|
{/* Footer */}
|
||||||
<div id="docs">
|
<footer className="footer">
|
||||||
<svg className="icon" role="presentation" aria-hidden="true">
|
<div className="container">
|
||||||
<use href="/icons.svg#documentation-icon"></use>
|
<p>© 2026 城市手册 - 记录每座城市的独特魅力</p>
|
||||||
</svg>
|
</div>
|
||||||
<h2>Documentation</h2>
|
</footer>
|
||||||
<p>Your questions, answered</p>
|
</div>
|
||||||
<ul>
|
</Router>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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